mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,6 +259,24 @@ 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.
|
||||
|
||||
+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?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
|
||||
|
||||
@@ -28,9 +28,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 +62,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)
|
||||
|
||||
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,22 @@ 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
|
||||
|
||||
# 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
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.26.0"
|
||||
VERSION = "1.45.2"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -18,8 +18,8 @@ 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, PLANS_DIR
|
||||
from .auth import may_write
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -32,6 +32,59 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_CHUNK = 64 * 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)
|
||||
|
||||
def _read() -> bytes | None:
|
||||
return path.read_bytes() if path.is_file() else None
|
||||
|
||||
blob = await hass.async_add_executor_job(_read)
|
||||
if blob is None:
|
||||
return web.Response(status=404)
|
||||
return web.Response(
|
||||
body=blob,
|
||||
content_type=_MIME.get(path.suffix.lower(), "application/octet-stream"),
|
||||
headers={"Cache-Control": "private, max-age=3600"},
|
||||
)
|
||||
|
||||
|
||||
class HouseplanUploadView(HomeAssistantView):
|
||||
"""POST /api/houseplan/upload — save a marker file, return its URL."""
|
||||
@@ -42,12 +95,8 @@ 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)
|
||||
|
||||
marker_id = "misc"
|
||||
filename: str | None = None
|
||||
@@ -99,5 +148,5 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
|
||||
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}
|
||||
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{safe_name}?v={mtime}", "name": filename}
|
||||
)
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.26.0"
|
||||
"version": "1.45.2"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Plan-file collection — pure, so it is unit-testable without Home Assistant.
|
||||
|
||||
The file system is not part of the configuration store's transaction, so who
|
||||
may delete a plan file, and when, is a correctness question rather than a
|
||||
housekeeping one. It lives here, apart from the WebSocket plumbing, precisely
|
||||
because it is the part that has to be reasoned about and tested.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .const import PLAN_ORPHAN_TTL_S
|
||||
from .validation import PLAN_EXTENSIONS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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 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 removed only once PLAN_ORPHAN_TTL_S has passed: 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)
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
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
|
||||
superseded = item.name in old_refs
|
||||
try:
|
||||
stale = item.stat().st_mtime < cutoff
|
||||
except OSError:
|
||||
stale = False
|
||||
if not superseded and not stale:
|
||||
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
|
||||
|
||||
@@ -47,13 +47,33 @@ 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
|
||||
|
||||
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:
|
||||
@@ -68,10 +88,24 @@ ROOM_SCHEMA = vol.All(
|
||||
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("open_to"): [str],
|
||||
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)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
@@ -84,14 +118,42 @@ 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,
|
||||
@@ -99,16 +161,17 @@ SPACE_SCHEMA = vol.Schema(
|
||||
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"): [
|
||||
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,7 +181,7 @@ 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.
|
||||
@@ -140,6 +203,10 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("link"): vol.Any(str, None),
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
|
||||
vol.Optional("controls"): vol.Any([str], None),
|
||||
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),
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", 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),
|
||||
@@ -153,10 +220,13 @@ MARKER_SCHEMA = vol.Schema(
|
||||
)
|
||||
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"): [str],
|
||||
vol.Optional("new_device_ids"): [str],
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""House Plan WS commands: layout, space configuration, plan uploads."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -13,8 +16,10 @@ from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
PLANS_DIR, PLANS_URL,
|
||||
CONTENT_URL, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import collect_plans
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
|
||||
@@ -22,6 +27,9 @@ from .validation import (
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register(hass: HomeAssistant) -> None:
|
||||
"""Register the WS commands."""
|
||||
@@ -32,6 +40,9 @@ 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_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 +59,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 ----------------
|
||||
@@ -68,11 +78,21 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
|
||||
|
||||
@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 +100,15 @@ 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
|
||||
await rt.store.async_save({"layout": msg["layout"], "rev": current_rev + 1})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": current_rev + 1})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -108,6 +135,153 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
connection.send_result(msg["id"], {"ok": True})
|
||||
|
||||
|
||||
@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
|
||||
target = dst / f.name
|
||||
if target.exists():
|
||||
# a different file already owns this name — do NOT silently
|
||||
# point the url at it; give the copy a unique name instead
|
||||
stem, suffix = f.stem, f.suffix
|
||||
i = 2
|
||||
while (dst / f"{stem} ({i}){suffix}").exists():
|
||||
i += 1
|
||||
target = dst / f"{stem} ({i}){suffix}"
|
||||
shutil.copy2(str(f), str(target))
|
||||
mapping[f.name] = target.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/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:
|
||||
"""Delete a marker's file folder — called only AFTER the config is 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
|
||||
|
||||
mid = sanitize_marker_id(msg["marker_id"])
|
||||
if not mid:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": False})
|
||||
return
|
||||
base = Path(hass.config.path(FILES_DIR)).resolve()
|
||||
target = (base / mid).resolve()
|
||||
if not str(target).startswith(str(base)) or target == base:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": False})
|
||||
return
|
||||
|
||||
def _rm() -> bool:
|
||||
if not target.is_dir():
|
||||
return False
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
return True
|
||||
|
||||
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/layout/delete",
|
||||
@@ -147,6 +321,7 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
|
||||
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/set",
|
||||
@@ -171,6 +346,15 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
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",
|
||||
@@ -179,6 +363,18 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
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).
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
|
||||
)
|
||||
except Exception: # noqa: BLE001 — see above: the commit stands regardless
|
||||
_LOGGER.exception("House Plan: collecting superseded plan 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 +415,25 @@ 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 superseded file is collected by `config/set` itself,
|
||||
# inside the write lock, once a revision that no longer references it has
|
||||
# been accepted (review R3-1). A crash in between leaves an orphan, which
|
||||
# the same collector removes on a later commit once it is old enough.
|
||||
#
|
||||
# `.` 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']}"
|
||||
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._curSpaceCfg.aspect || 1)) } };
|
||||
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 / (c._curSpaceCfg.aspect || 1);
|
||||
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,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 = {};
|
||||
@@ -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,124 @@
|
||||
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 / (c._curSpaceCfg.aspect || 1);
|
||||
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 aspect = c._curSpaceCfg.aspect || 1;
|
||||
const c1 = c._roomCenter(r1);
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
|
||||
// радиус 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 / aspect), 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,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,46 @@
|
||||
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];
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
|
||||
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) => {
|
||||
@@ -16,7 +16,7 @@ const S = () => page.evaluate(() => {
|
||||
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,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;
|
||||
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||
// r1|r2 делят стену x=0.55 → клик по ней открывает границу
|
||||
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
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'));
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
|
||||
c.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 / (c._curSpaceCfg.aspect || 1);
|
||||
// 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.sentAspect = sentF1?.aspect;
|
||||
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.atticAspect = attic?.aspect;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.44.8 и сверено с кодом
|
||||
checkAll(res, {
|
||||
reloadHappened: true,
|
||||
sentPlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
|
||||
sentAspect: 1.6,
|
||||
sentTitle: 'Ground',
|
||||
livePlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
|
||||
dialogClosed: true,
|
||||
atticSaved: true,
|
||||
atticHasPlan: true,
|
||||
atticAspect: 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,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,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');
|
||||
@@ -46,5 +47,15 @@ const res = await page.evaluate(async () => {
|
||||
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,
|
||||
"atticAspect": 1,
|
||||
"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,11 +1,11 @@
|
||||
// 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];
|
||||
}, [nx, ny]);
|
||||
const out = {};
|
||||
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._toggleMarkup(); c._tool='split';});
|
||||
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool='split';});
|
||||
// living room (r1) has walls at y=0.05 which are NOT grid nodes; click near the wall
|
||||
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)
|
||||
@@ -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,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);
|
||||
|
||||
+952
-215
File diff suppressed because one or more lines are too long
Vendored
+952
-215
File diff suppressed because one or more lines are too long
+97
-1
@@ -177,9 +177,45 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
|
||||
| `houseplan/layout/update` | `device_id`, `pos` | `{ok}` |
|
||||
| `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/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
|
||||
|
||||
**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`): superseded files go immediately, other unreferenced
|
||||
uploads only once `PLAN_ORPHAN_TTL_S` has passed, since a fresh one may belong
|
||||
to a transaction still in flight. The `.` between id and token is load-bearing —
|
||||
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.
|
||||
|
||||
**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/`.
|
||||
|
||||
@@ -212,3 +248,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.
|
||||
|
||||
@@ -1,5 +1,694 @@
|
||||
# Changelog
|
||||
|
||||
## v1.45.2 — 2026-07-27 (hardening from the v1.45.1 review: R4-1, R4-2)
|
||||
- **A failed cleanup no longer reports an accepted save as an error (R4-1).**
|
||||
Collecting superseded plan files runs after the configuration is already
|
||||
stored, but an error while listing the directory — it can vanish or turn
|
||||
unreadable between the check and the walk — propagated out of `config/set`.
|
||||
The client then saw a failure for a revision the server had committed, and its
|
||||
retry came back as a conflict. The collector now reports "nothing collected"
|
||||
instead of raising, and `config/set` logs and proceeds: the event fires and
|
||||
the new revision is returned.
|
||||
- **One signing request per url instead of one per render (R4-2).** The pending
|
||||
set was cleared when the batch went out rather than when it came back, so
|
||||
while a `content/sign` call was in flight every re-render queued another one —
|
||||
six calls where one was needed, and far worse on a socket that is slow rather
|
||||
than merely busy. Queued and in-flight are now separate states, a failure
|
||||
backs off (2 s doubling to 60 s) instead of retrying on the next frame, and a
|
||||
request that never settles stops blocking retries after 15 s. A late answer
|
||||
arriving after the card was torn down no longer triggers a render.
|
||||
- Tests: eight unit tests for the signer with hand-settled promises (four fail
|
||||
on v1.45.1), a backend test asserting a broken collector still yields a
|
||||
successful save with a usable revision, and the pure-collector test extended
|
||||
to a disappearing directory.
|
||||
|
||||
## v1.45.1 — 2026-07-27 (follow-up review of v1.45.0: R3-1, R3-2)
|
||||
- **Collecting old plan files moved into the config transaction (R3-1, high).**
|
||||
v1.45.0 made the upload safe but handed the deletion to the client: after a
|
||||
successful save the card asked the backend to remove everything except the
|
||||
file it had just committed. Two open editors could not be ordered — a delayed
|
||||
request from one client deleted the plan the other had just saved, and the
|
||||
accepted configuration was left pointing at nothing, which is the exact damage
|
||||
copy-on-write was added to prevent. The `houseplan/plan/cleanup` command is
|
||||
gone. `config/set` now collects inside its own write lock, comparing the
|
||||
configuration it replaced with the one it accepted: a file the old revision
|
||||
referenced and the new one does not is removed, and any other unreferenced
|
||||
upload is left alone until it is an hour old, because a fresh one may belong
|
||||
to a transaction that has not committed yet.
|
||||
- **The static space card shows its plan background again (R3-2).** It signed
|
||||
the url and then threw the result away — `getCardSize()` mutated a throwaway
|
||||
model while `render()` rebuilt its own from the config — so the `<image>` kept
|
||||
requesting the protected path and got a 401 on every render. Both cards now
|
||||
share one signer, which also gives the static card the batching, the
|
||||
expiry handling and the periodic re-signing the main card already had. Its
|
||||
pending set is released in `finally`, so a single failed request no longer
|
||||
wedges a url for the life of the page.
|
||||
- New tests: five backend cases for the two-client interleavings from the report
|
||||
(late commit, uncommitted upload, aged orphan, foreign files, rejected save),
|
||||
the collector extracted to a pure module and unit-tested, and
|
||||
`smoke_space_card_bg` for the signed background — it fails on v1.45.0 with the
|
||||
raw url in the DOM.
|
||||
|
||||
## v1.45.0 — 2026-07-27 (external review of v1.44.8: R2-1, R2-2, R2-3)
|
||||
- **A rejected save can no longer damage a working plan (R2-1, high).** The
|
||||
plan file was written to its final name — deleting the previous extension on
|
||||
the way — *before* the revision-checked config write. If that write was then
|
||||
rejected (revision conflict, validation, lost connection), the live plan had
|
||||
already been replaced, or the stored config was left pointing at a file that
|
||||
no longer existed. Uploads now go to a versioned name
|
||||
(`<space>.<token>.<ext>`) and nothing is deleted; the card asks the backend to
|
||||
drop the superseded files only after the config write is accepted. A crash in
|
||||
between leaves one orphan, which the next successful upload collects.
|
||||
- **Signed urls no longer expire for good on long-lived screens (R2-2).** The
|
||||
backend signs at most 200 paths per request and silently ignores the rest,
|
||||
while the card sent its whole cache in one call and treated any cached entry
|
||||
as valid forever. Past 200 attachments the later ones stopped being refreshed
|
||||
and, 24 hours in, quietly broke. Requests are now batched to the shared limit,
|
||||
entries carry their age (aging urls keep working while a replacement is
|
||||
fetched, expired ones are never served), and the cache is pruned to the urls
|
||||
the current config still references.
|
||||
- **Room climate is computed once per update instead of once per room (R2-3).**
|
||||
Each room asked for temperature and humidity separately, and every ask
|
||||
rescanned the entire entity registry: with 60 rooms and 2000 entities that is
|
||||
~120 traversals per render, enough to spend a whole frame on metadata that had
|
||||
not changed. One pass now builds a map for all areas, keyed on the Home
|
||||
Assistant snapshot, so fresh states are always observed while unrelated
|
||||
re-renders cost nothing. Measured in the smoke: 133 registry scans per update
|
||||
before, 2 after — and no longer growing with the number of rooms.
|
||||
- `smoke_ux_fixes` wrote its screenshot to a hard-coded `/tmp` path and could
|
||||
not run on Windows; it uses the OS temp directory now.
|
||||
- New tests: `smoke_plan_upload_reject` (cleanup happens only after an accepted
|
||||
save), `smoke_sign_cap` (201 urls, batching, pruning, expiry),
|
||||
`smoke_climate_once` (scan count does not grow with rooms), plus backend
|
||||
coverage for the versioned plan names and unit tests for the new helpers.
|
||||
|
||||
## v1.44.8 — 2026-07-27
|
||||
- **An uploaded plan is actually attached to the space.** `_saveSpaceDialog`
|
||||
held a reference to the space object across the `await` that uploads the
|
||||
image. Every `houseplan_config_updated` event runs `_reloadConfigOnly()`,
|
||||
which *replaces* `_serverCfg` — so the reference became an orphan and
|
||||
`plan_url`, `aspect`, the title and all display settings were written into a
|
||||
detached object while the save shipped the untouched config. The file landed
|
||||
on disk, the plan never appeared, and re-saving could not help. Creating a
|
||||
space in that window lost the space entirely.
|
||||
The upload now happens *before* the config is touched, and nothing is held
|
||||
across an await.
|
||||
- **`_saveConfigNow` marks the write in flight** (`_cfgWriting`), like the
|
||||
debounced writer already did, so a remote revision arriving mid-save defers
|
||||
its reload instead of replacing the config underneath it (audit L2 extended
|
||||
to this path).
|
||||
- Regression test `demo/smoke_plan_upload_race.mjs` fails on v1.44.7 and passes
|
||||
here.
|
||||
|
||||
## v1.44.7 — 2026-07-27
|
||||
- **Plan backgrounds are visible again (regression from v1.44.5).** Since the
|
||||
content endpoint requires authentication, the card asks the backend to sign
|
||||
the plan's url — but the signing happened inside the *memoized* space model,
|
||||
which is cached on the config fingerprint. The unsigned url froze in that
|
||||
cache, so the signature never reached the `<image>` element and the plan never
|
||||
loaded. The url is now resolved at render time, outside the cache. (PDF links
|
||||
were unaffected — they already resolved at render time.)
|
||||
- **No more "failed login attempt" from your own IP.** While the plan was
|
||||
broken the browser kept requesting the unsigned path, which returns 401 and
|
||||
makes Home Assistant raise a login-attempt warning for the viewer's own
|
||||
address. The card now renders nothing until the signature is in hand, so an
|
||||
unsigned request is never made.
|
||||
- **Long-lived screens no longer blink.** The 12-hour re-signing used to drop
|
||||
every signature and wait for new ones; it now keeps the current urls until the
|
||||
replacements arrive, so a wall tablet never shows an empty plan.
|
||||
- Regression test `demo/smoke_plan_signed.mjs` fails on v1.44.6 and passes here.
|
||||
|
||||
## v1.44.6 — 2026-07-27
|
||||
- **Only room *air* counts as room climate.** After v1.44.5 started reading the
|
||||
area registry instead of the visible icons, every hidden temperature entity in
|
||||
the area became a candidate — including ones that measure something other than
|
||||
the air. Three guards now run before averaging: entities marked
|
||||
diagnostic/config are skipped, entities from curated-out integrations are
|
||||
skipped, and entity ids naming a non-air medium are skipped
|
||||
(`water`, `coolant`, `flow_temp`, `return_temp`, `target`, `setpoint`, `chip`,
|
||||
`cpu`, `processor`, `board`, `device_temp`, `batter`, `freezer`, `fridge`,
|
||||
`oven`, `kettle`, `boiler`).
|
||||
On a live 60-area install this removed four real false positives: a NAS
|
||||
processor temperature, the water in a smart kettle, a 90 °C sauna heater and a
|
||||
virtual `better_thermostat` duplicating the real sensor.
|
||||
- **New icon rules:** kettles/thermopots get `mdi:kettle`, saunas
|
||||
(`sauna`, `harvia`, `парная`) get `mdi:hot-tub` — previously both fell through
|
||||
to the generic thermometer rule, which is also what made them count as room
|
||||
climate.
|
||||
|
||||
## v1.44.5 — 2026-07-27
|
||||
- **Room climate now counts every sensor in the area**, including devices that
|
||||
are not placed on the plan (hidden by curation or by you). Previously the
|
||||
average was taken over the visible icons only, so hiding a thermometer
|
||||
silently removed it from the room card, the tooltip and the temperature fill.
|
||||
Curation still applies (fridges, TRVs and chip-temperature plugs stay out),
|
||||
and an explicit per-room source still wins.
|
||||
- The room tooltip no longer says "open the area" — clicking a room stopped
|
||||
navigating in v1.40.1; the link icon on the room card does that.
|
||||
|
||||
|
||||
## v1.44.4 — 2026-07-27 (audit follow-up: B2, B5, L4)
|
||||
- **One authorization policy (B2).** The HTTP upload view still failed **open**
|
||||
when the config entry was unavailable while the WebSocket path failed closed —
|
||||
the two had drifted apart. Both now call the same `may_write` helper, which
|
||||
denies non-admins whenever the policy cannot be read.
|
||||
- **NaN/Infinity refused on every coordinate (B5).** The finite-number check
|
||||
guarded only layout positions; room rects, polygon vertices, `view_box` and
|
||||
opening coordinates accepted `"NaN"`, which serializes to `null` and corrupts
|
||||
the stored geometry permanently. The `MAX_OPENINGS` cap was defined but never
|
||||
wired in — the openings list was unbounded.
|
||||
- **Drag hardening (L4 sub-item).** The tolerant `setPointerCapture` wrapper is
|
||||
now used by every drag pipeline (device, label, resize), not just openings —
|
||||
an inactive pointer id could kill a drag outright. Decor shapes gained a
|
||||
bounds clamp: they can no longer be dragged far outside the plan and saved
|
||||
there.
|
||||
|
||||
## v1.44.3 — 2026-07-27 (fix: plans and manuals load again)
|
||||
- **The authenticated content endpoint had no working browser path.** v1.43.0
|
||||
closed the security hole correctly, but Home Assistant authenticates HTTP
|
||||
requests by a Bearer header or an `authSig` signed path — and an SVG
|
||||
`<image href>` or a plain `<a href>` sends neither. Plan backgrounds and PDF
|
||||
links returned **401** on a real dashboard (reproduced live before the fix).
|
||||
The card now asks the backend to sign what it displays
|
||||
(`houseplan/content/sign`, 24 h, bound to the session's refresh token, only
|
||||
for our own endpoint), re-renders when signatures arrive, and refreshes them
|
||||
every 12 hours so wall tablets keep working. A backend test fetches a signed
|
||||
url **without** an Authorization header and asserts 200, and 401 without the
|
||||
signature.
|
||||
|
||||
|
||||
> 🇷🇺 Русская версия: [CHANGELOG.ru.md](CHANGELOG.ru.md) (записи с v1.42.0).
|
||||
|
||||
## v1.44.2 — 2026-07-27 (external code review: CR-1…CR-3)
|
||||
|
||||
A second, adversarial review (of v1.44.0) produced three findings; all are
|
||||
addressed.
|
||||
|
||||
- **The lock invariant is now precise and enforced (CR-1).** The reviewer was
|
||||
right that "locks can never be actuated from the plan" was too absolute a
|
||||
claim: the door card's Unlock button does call the service. That button is a
|
||||
deliberate product decision, so the invariant is restated where it belongs
|
||||
("never by an accidental tap; exactly one labeled surface"), unlocking now
|
||||
**asks for confirmation**, and a new smoke exercises all five actuation paths
|
||||
to prove icons, `controls[]` and the device card still refuse locks outright.
|
||||
- **Attachment migration became transactional (CR-2).** Rebinding a marker used
|
||||
to MOVE its files before the revision-checked config save — if that save was
|
||||
rejected, the stored config kept the old urls while the files had already
|
||||
left. Now the server **copies**, the config is committed, and only then the
|
||||
old folder is removed (`houseplan/files/cleanup`).
|
||||
- **Failed or partial migrations no longer rewrite urls (CR-3).** The copy
|
||||
reports an exact `{source: written}` mapping; only confirmed copies are
|
||||
rewritten, name collisions get a unique name instead of silently linking a
|
||||
pre-existing file, and a failed migration surfaces as a toast with the links
|
||||
left pointing at the still-existing originals.
|
||||
|
||||
## v1.44.1 — 2026-07-27
|
||||
- Added the community chat everywhere users look: **https://t.me/ha_houseplan**
|
||||
(badge and header line in both READMEs, a "Getting help" section, the issue
|
||||
template contact links, CONTRIBUTING, STATUS and SCOPE).
|
||||
|
||||
## v1.44.0 — 2026-07-27 (user feedback: control first)
|
||||
|
||||
- **The device card is now a control surface.** It opens with the device's
|
||||
controllable entities: lights, switches and fans toggle straight from the
|
||||
card with finger-sized buttons, covers/locks/climate open Home Assistant's
|
||||
own more-info. Model, links and PDF manuals moved below — on a wall tablet
|
||||
this card is for running the home, not for reading documentation (field
|
||||
report). Config and diagnostic entities are not listed; locks still never
|
||||
toggle from a card tap.
|
||||
- **"This device is a light source"** — a new per-device flag. A smart switch
|
||||
driving ordinary (dumb) fixtures now casts a glow in the "Light sources"
|
||||
fill without inventing a light-group helper: the glow follows the switch, or
|
||||
the lights bound under "Controls light sources" when they are set.
|
||||
|
||||
## v1.43.3 — 2026-07-27 (user feedback: discoverability and touch)
|
||||
|
||||
- **Room settings were unfindable.** The gear added in v1.42.0 lived inside the
|
||||
room label at 0.9em of its font and 60% opacity — a few pale pixels on a
|
||||
normal plan. It is now a pill button "⚙ Room" of a fixed, readable size that
|
||||
does not shrink with the card font, and it appears on **unnamed rooms too**
|
||||
(that is where you name them). This also unblocks the font-size sliders,
|
||||
which nobody could reach.
|
||||
- **Metrics line enlarged** from 0.62 to 0.75 of the room name — the reporter
|
||||
could scale the name but the sensor line stayed unreadable on a tablet. The
|
||||
per-room and per-space multipliers still apply on top.
|
||||
- **Touch tooltips, take two.** The `(hover: none)` guard was not enough: some
|
||||
devices, skins, styluses and paired mice report `hover: hover`, so tips still
|
||||
stuck under the finger. The card now also latches on the first touch/pen
|
||||
pointer event and drops any open tooltip on touch.
|
||||
|
||||
## v1.43.2 — 2026-07-27 (external audit: the test layer)
|
||||
|
||||
- **The smoke suite can finally fail (T1).** All 48 headless-browser smokes used
|
||||
to print booleans and exit 0 — a regression was visible in their own output
|
||||
and still reported success. `demo/serve.mjs` now exports `check`/`checkAll`/
|
||||
`finish`: every fact is asserted by name, mismatches and uncaught exceptions
|
||||
inside the card set a non-zero exit code. Verified by deliberately breaking
|
||||
the kiosk editor guard: the matching smoke went red.
|
||||
- **The suite runs in CI (T2)** as a `smoke` job gated on `frontend`, against a
|
||||
freshly built bundle (the committed `demo/srv/assets` copy is a snapshot and
|
||||
would have tested stale code), uploading per-file logs on failure.
|
||||
- **`docs/TESTING.md` reconciled (T3).** `[auto]` now means "a named check
|
||||
exists that fails when this breaks", and each such line names it; 72 lines
|
||||
whose automation was aspirational are honestly marked `[manual]`. Two
|
||||
long-standing contradictions fixed: the "ZERO edit buttons in View" line
|
||||
(wrong since v1.30.1) and the opening-click line (true again since v1.43.1).
|
||||
- Three smokes carried expectations that predate v1.39.0/v1.25 and quietly
|
||||
described old behaviour; they now test the current contract.
|
||||
|
||||
## v1.43.1 — 2026-07-27 (external audit: P1 fixes)
|
||||
|
||||
- **Render cost (L1).** Home Assistant replaces `hass` on every state change in
|
||||
the home, and each of those renders recomputed the whole plan geometry —
|
||||
`_openPairs()` ran once per room (O(rooms³) collinear-overlap math) and the
|
||||
space model was rebuilt twice. Both are now memoized on the config's
|
||||
structural fingerprint and hoisted out of the per-room loop; cache
|
||||
invalidation happens synchronously at mutation time, not inside the debounce.
|
||||
- **Opening tap vs drag (L4).** Dragging a door/window had no movement
|
||||
threshold, so any pointer jitter counted as a drag: the properties dialog
|
||||
never opened and an unchanged config was written (which then fed the L2 race).
|
||||
Now the same 3 px threshold as every other drag pipeline, and the write only
|
||||
happens when the geometry actually changed.
|
||||
- **Concave rooms (G2).** Containment used the arithmetic mean of the vertices
|
||||
as an "interior point" — which lies OUTSIDE U- and L-shaped rooms, so island
|
||||
rooms in them were rejected as overlaps and their holes never rendered. A
|
||||
real interior point is computed instead (`interiorPoint`).
|
||||
- **Wall dedup (G3).** `segKey` ordered endpoints by raw floats but printed
|
||||
rounded ones, so one shared wall could produce two keys and be drawn twice.
|
||||
Rounds first, then orders.
|
||||
- **Backend hardening (B2–B5).** The write-authorization check now fails
|
||||
**closed** when the config entry is unavailable (it used to allow writes
|
||||
during a reload); `layout/set` supports `expected_rev` and conflicts like the
|
||||
config store; a `config/set` without `expected_rev` over a non-empty store
|
||||
logs a warning; coordinates reject NaN/Infinity, and spaces/rooms/markers/
|
||||
decor/layout have generous size caps.
|
||||
|
||||
## v1.43.0 — 2026-07-27 (external audit: P0 fixes)
|
||||
|
||||
An external code audit of v1.41.1 found four critical issues. All four are fixed
|
||||
and covered by regression tests.
|
||||
|
||||
- **Silent data loss on save (L2).** A debounced config write read the config at
|
||||
fire time, so a `houseplan_config_updated` event arriving in between replaced
|
||||
it and the user's edit vanished with no error — reproducible in a single tab.
|
||||
The debounce now supports `flush()`/`pending()`, a reload flushes the pending
|
||||
write first and defers while a write is in flight, and a failed reload finally
|
||||
reports instead of staying silent.
|
||||
- **Split corrupted room geometry (G1).** A cut starting and ending on the SAME
|
||||
wall (carving a niche — a natural action) produced two overlapping,
|
||||
self-intersecting rooms whose areas summed to twice the original, and the
|
||||
overlap guard did not catch it. Same-edge cuts now carve the niche correctly,
|
||||
and a partition invariant (parts must sum to the original) rejects anything
|
||||
else.
|
||||
- **Plans and uploaded files were served without authentication (B1).** Anyone
|
||||
who could reach the HA endpoint could fetch floor plans and attached manuals
|
||||
without logging in. They are now served by an authenticated view; stored
|
||||
legacy URLs are rewritten on read, so nothing breaks. **The old public paths
|
||||
disappear after a Home Assistant restart.**
|
||||
- **Dialogs could resurrect and blank the card (L3).** Closing a dialog while
|
||||
its save was in flight, on a failed save, spread `null` into a truthy husk;
|
||||
the renderer then threw and the card went blank until reload. Guarded in all
|
||||
four save routines; the error toast still fires.
|
||||
|
||||
## v1.42.2 — 2026-07-26
|
||||
- Touch devices no longer pop hover tooltips on every tap (field feedback:
|
||||
"extra labels appear and get in the way on a tablet"). Hover tooltips are
|
||||
desktop-only now; on touch the same data lives in room cards and the
|
||||
long-press device card.
|
||||
|
||||
## v1.42.1 — 2026-07-26 (room-card font sizes)
|
||||
- Closing the "can't change the font size" feedback: **three sliders**.
|
||||
Space settings gained a base room-card font size for the whole space;
|
||||
Room settings gained independent sizes for the room NAME and the METRICS
|
||||
line (50–300% each). Effects multiply — and stack with the card's corner
|
||||
resize and the kiosk per-screen multiplier as before.
|
||||
- Both dialogs show a **live sample card** that follows the sliders as you
|
||||
drag them.
|
||||
|
||||
## v1.42.0 — 2026-07-26 (room settings — the third tier)
|
||||
- **Settings now have four tiers**: global → space → room → device; the more
|
||||
specific tier overrides the more general one (owner's principle, fixed in
|
||||
ARCHITECTURE). This release adds the ROOM tier.
|
||||
- Every room card in the Plan editor gained a **gear**: rename the room,
|
||||
change its HA area, override the **fill type** for this room only (works
|
||||
in glow spaces too — 'none' pulls the room out of the darkness), and pick
|
||||
an explicit **temperature / humidity source** — any HA device or entity —
|
||||
instead of the default room average. The source feeds the room card, the
|
||||
tooltip and the temperature fill, and works for rooms without an HA area
|
||||
(the user-feedback case: a custom template sensor bound to a room).
|
||||
- The same settings section appears in the room dialog right after closing a
|
||||
contour.
|
||||
|
||||
## v1.41.2 — 2026-07-26
|
||||
- Fixed attached PDF manuals breaking after a marker is rebound to another
|
||||
device: the id changes, but the uploaded files stayed in the OLD id's
|
||||
folder — orphaned and eventually lost (that is exactly how the sauna
|
||||
heater's manuals died). Rebinding now moves the files server-side
|
||||
(`houseplan/files/migrate`) and rewrites the attached urls.
|
||||
|
||||
## v1.41.1 — 2026-07-24 (docs)
|
||||
- README (en/ru) reworked for discoverability: keyword-rich hero ("interactive
|
||||
floor plan card for Home Assistant"), badges, a feature-highlights list
|
||||
covering glow, controls, kiosk, virtual walls and room cards; the kiosk
|
||||
recipe moved next to Installation. Demo GIF slot ready for the new capture.
|
||||
|
||||
## v1.41.0 — 2026-07-24 (kiosk mode for wall devices)
|
||||
- New card option **`kiosk: true`** (also in the GUI editor): the full View
|
||||
experience — live states, glow, lamp taps, info cards, locks — with no
|
||||
header and no editors at all, sized for a wall tablet or TV.
|
||||
- **Swipe** left/right switches spaces at 1:1 zoom (wrap-around, dots
|
||||
indicator); while zoomed the gesture pans as usual; **double tap** resets
|
||||
zoom. **`cycle: N`** auto-advances spaces every N seconds (pauses for a
|
||||
minute after any touch) — for TVs and against OLED burn-in.
|
||||
- **Per-screen sizes**: long-press an empty spot (3 s) to open a popover with
|
||||
icon and room-card-font multipliers, stored in this device's localStorage —
|
||||
every tablet/TV tunes itself once.
|
||||
- Recipe for a full-screen wall dashboard (panel view + kiosk-mode/companion
|
||||
settings) added to the README.
|
||||
|
||||
## v1.40.2 — 2026-07-24
|
||||
- Default icon rules: smart speakers (Yandex/Alice stations, «колонка»,
|
||||
generic speakers) now get **mdi:speaker**; mdi:soundbar stays for actual
|
||||
soundbars. Applies where the icon rules haven't been customized.
|
||||
|
||||
## v1.40.1 — 2026-07-23
|
||||
- Rooms are no longer clickable in View (default cursor, empty space does
|
||||
nothing). Instead the room card shows a small **open-in-new icon** after
|
||||
the name — clicking it opens the HA area. Rooms without an area (and all
|
||||
editors) have no icon.
|
||||
|
||||
## v1.40.0 — 2026-07-23 (smart guides)
|
||||
- **Alignment helper in every editor**: while drawing an outline, a cut or a
|
||||
decor shape, and while dragging icons, room cards or decor, thin dashed
|
||||
guides appear from the nearest object sharing your X and/or Y (one per
|
||||
axis, with a marker dot at the source) — lamps line up, lines end exactly
|
||||
above the end of a parallel line. Candidates follow the context: room
|
||||
vertices and path points in the Plan editor, other icons in the Device
|
||||
editor, decor endpoints/corners plus room vertices in the Background
|
||||
editor, other room cards while dragging one.
|
||||
- The cursor badge now shows **length · angle** and turns green when the
|
||||
segment's angle is a multiple of 45°. Guides are pure indication — the
|
||||
grid keeps owning the actual position.
|
||||
|
||||
## v1.39.0 — 2026-07-23 (lights toggle by default)
|
||||
- Pure light sources — devices whose primary entity is a `light` (bulbs,
|
||||
chandeliers, night lights, light groups) — now **toggle on click by
|
||||
default**, right from auto-placement, no per-device setting needed.
|
||||
Devices where light is a side function (a kettle's backlight: its primary
|
||||
is a sensor) keep the Device-card default. An explicit per-device choice
|
||||
always wins. The device dialog shows the effective default.
|
||||
|
||||
## v1.38.4 — 2026-07-23
|
||||
- Plan editor: the DERIVED wall segments (the markup layer's solid lines)
|
||||
are now trimmed under open boundaries as well — v1.38.3 only trimmed the
|
||||
room outlines, so the virtual wall still looked solid in the editor.
|
||||
(`cutSegments` extracted as the shared workhorse; outlineWithout reuses it.)
|
||||
|
||||
## v1.38.3 — 2026-07-23
|
||||
- The Plan editor now shows open boundaries as a true dash as well: the blue
|
||||
markup outlines are trimmed under the open stretches (rooms picked for
|
||||
merge/split keep their full amber highlight).
|
||||
|
||||
## v1.38.2 — 2026-07-23
|
||||
- The card now **remembers where you were**: the selected space and the active
|
||||
editor survive navigation and closing the tab (localStorage; edit modes are
|
||||
restored for admins only). A `#space=` deep link still wins over the saved
|
||||
space. This reverses the earlier "always start in View" rule — the owner's
|
||||
call.
|
||||
|
||||
## v1.38.1 — 2026-07-23 (tap action cleanup, right-click more-info)
|
||||
- The per-device action is now one of three: **Device card** (renamed from
|
||||
"Info card", the default), HA more-info, Toggle. The confusing "As the card
|
||||
default" option is gone — along with the card editor's global tap setting
|
||||
(it is ignored if present in old configs). Explicit per-device choices are
|
||||
untouched. RU wording: «по нажатию» instead of «по тапу».
|
||||
- **Right click** on a device icon in View mode always opens HA's more-info
|
||||
dialog (editors keep the native browser menu; a virtual marker without an
|
||||
entity opens its device card).
|
||||
|
||||
## v1.38.0 — 2026-07-23 (binding section redesign)
|
||||
- The device dialog's binding section is compact now: two radio buttons —
|
||||
**Virtual device** and **Pick from the HA list** — with a **Show entities**
|
||||
checkbox (adds every entity of the devices to the list; groups and helpers
|
||||
are always listed). The searchable dropdown appears only in HA mode and
|
||||
collapses once you pick. Save is disabled until a binding is chosen.
|
||||
The binding logic itself is unchanged.
|
||||
|
||||
## v1.37.3 — 2026-07-23
|
||||
- Open boundaries now render as a **true dash**: the rooms' solid outlines are
|
||||
trimmed under the open stretch (outlineWithout) instead of dashes being
|
||||
painted over a solid line, and the dashed layer moved **above the glow
|
||||
pools** so light never covers it.
|
||||
|
||||
## v1.37.2 — 2026-07-23
|
||||
- Glow falloff tuned: full brightness for the inner 70% of the radius,
|
||||
gradient on the outer 30% (was 80/20).
|
||||
|
||||
## v1.37.1 — 2026-07-23
|
||||
- Open-boundary tool polish: the cursor stays default and only turns into a
|
||||
pointer near a wall shared by two rooms; hovering previews the exact
|
||||
stretch that would become open (amber dashed) — or red solid when the
|
||||
boundary is already open and the click would close it.
|
||||
|
||||
## v1.37.0 — 2026-07-23 (open boundaries — virtual walls)
|
||||
- Rooms divided only by zoning can now share an **open boundary**: the new
|
||||
"Open boundary" tool in the Plan editor toggles it with a click on the wall
|
||||
two rooms share. The stretch renders dashed; while the tool is active open
|
||||
boundaries highlight amber. Stored as `room.open_to` links by room id, so
|
||||
redrawing/merging neighbours doesn't break them.
|
||||
- In the light-sources fill, light flows through open boundaries freely —
|
||||
transitively across the whole connected zone (kitchen ↔ living ↔ hall as
|
||||
one open space), still limited by the glow radius. Door sectors now work
|
||||
from any outer wall of the zone.
|
||||
|
||||
## v1.36.4 — 2026-07-23
|
||||
- Glow: sharper light edge — the pool is fully lit for the inner 80% of the
|
||||
radius, the gradient falloff lives only in the outer 20%.
|
||||
|
||||
## v1.36.3 — 2026-07-23
|
||||
- Glow: fixed dark wedges appearing INSIDE a lit room near some doorways.
|
||||
The room outline and the door sectors were subpaths of a single clip path;
|
||||
with opposite winding directions the nonzero fill rule cancelled their
|
||||
overlap. Each contour is now its own clipPath child (children always
|
||||
union), so sectors only ever ADD light.
|
||||
|
||||
## v1.36.2 — 2026-07-23
|
||||
- **Glow radius is now per source**: every device dialog gained a "Glow
|
||||
radius" field (in your HA units; empty = the global default from general
|
||||
settings, shown as the placeholder). A kettle's night light can glow half
|
||||
a meter while the ceiling lamp floods the room. Door sectors use the same
|
||||
per-source radius.
|
||||
|
||||
## v1.36.1 — 2026-07-23
|
||||
- Fixed tap-toggle "doing nothing" on lamps whose individual `light.*` entity
|
||||
is **hidden in the registry** (the usual setup when lamps are folded into a
|
||||
light group): the primary entity fell through to a visible config switch
|
||||
(do-not-disturb) or an identify button, and the click toggled THAT.
|
||||
Primary selection now works in tiers — domain priority beats hiddenness,
|
||||
so a hidden light still wins over a visible config switch, while visible
|
||||
entities of the same domain keep winning over hidden ones.
|
||||
|
||||
## v1.36.0 — 2026-07-23 (wall switches that really switch)
|
||||
- Markers gained **"Controls light sources"**: bind any set of `light.*` /
|
||||
`switch.*` entities to an icon. With tap action **Toggle**, a click flips
|
||||
them all with HA-group semantics — any on → all off, all off → all on — in
|
||||
one service call. Covers stateless remotes, one-switch-many-lights and
|
||||
dumb wall switches (place a virtual marker; no HA entity needed).
|
||||
- The icon mirrors its targets: on when any target is on, tinted by the first
|
||||
lit RGB light. The info card lists every target with its state. Controls
|
||||
fire only on the explicit per-marker Toggle (owner's decision); locks and
|
||||
other domains can never be group-controlled.
|
||||
|
||||
## v1.35.0 — 2026-07-23 (glow fill: dark house, glowing lamps)
|
||||
- New fill mode **"Light sources"**: the whole house is painted with a single
|
||||
configurable darkness color, and every lit lamp casts a radial pool of light
|
||||
around itself. The pool color comes from the lamp's `rgb_color`, else its
|
||||
color temperature (blackbody conversion), else a configurable default;
|
||||
brightness scales the intensity.
|
||||
- Pools are clipped by the source's room — **plus the sector through each
|
||||
doorway** (rays from the source to the door edges, out to the glow radius),
|
||||
so light spills into neighbouring rooms through doors. Entrance doors (no
|
||||
room behind) spill nothing; windows don't spill. No shadow casting: islands
|
||||
and furniture do not block light (deliberate limitation).
|
||||
- The glow radius is configured in General settings in your HA unit system
|
||||
(meters or feet; stored in cm, default 3 m). The palette gained a "glow"
|
||||
group: house darkness + default light color/intensity.
|
||||
|
||||
## v1.34.0 — 2026-07-22 (island rooms)
|
||||
- **Nested rooms are now legal**: draw a contour fully inside an existing room
|
||||
(or around one) — a column in a ring-shaped room, an inner room, a wardrobe
|
||||
island. The parent room's fill is rendered with an evenodd hole, so a ring
|
||||
paints as a ring; the island itself stays clickable and can carry its own
|
||||
area, fill and devices. Partial overlaps and duplicate outlines are still
|
||||
rejected (`roomsOverlap` reworked; `polyContainsPoly`/`islandsOf` helpers,
|
||||
unit-tested). The per-click "point inside a room" rejection is gone —
|
||||
validation happens once, when the outline closes.
|
||||
|
||||
## v1.33.5 — 2026-07-22
|
||||
- Editor tabs got extended tooltips explaining what each editor is for (plan
|
||||
geometry vs device icons vs visual decor).
|
||||
|
||||
## v1.33.4 — 2026-07-22
|
||||
- Editing a device no longer makes its icon jump. Changing the HA binding
|
||||
(which changes the marker id) migrates the saved position to the new id;
|
||||
changing the room within the same space keeps the icon exactly where it
|
||||
stands (previously it re-centered in the new room). Only a brand-new icon,
|
||||
or a move to a room in a different space, is centered.
|
||||
|
||||
## v1.33.3 — 2026-07-22
|
||||
- Device dialog: when no icon is set explicitly, the icon picker no longer
|
||||
looks empty — it shows the **auto-derived icon** (from the icon rules /
|
||||
device class) as a placeholder, with an "Auto: mdi:…" hint line underneath.
|
||||
Picking an explicit icon replaces it as before; clearing returns to auto.
|
||||
|
||||
## v1.33.2 — 2026-07-22
|
||||
- Removed the **Reset** button from the Device editor. It wiped the entire
|
||||
layout — positions of all devices, room cards and their scales across every
|
||||
space — behind a single confirm. Low value, high blast radius.
|
||||
|
||||
## v1.33.1 — 2026-07-22
|
||||
- The dot grid is now shown in **every editor** (Plan, Devices, Background),
|
||||
not just Plan — an instant visual cue that you are editing.
|
||||
- In the Background editor, rooms, devices, openings and labels fade to 35%
|
||||
opacity so the decor you are drawing stands out; decor itself stays fully
|
||||
opaque. Other modes are unaffected.
|
||||
|
||||
## v1.33.0 — 2026-07-22 (background editor)
|
||||
- New third mode: **Background editor**. Draw purely visual decor on the plan —
|
||||
lines, rectangles, ovals and text labels that never interact with rooms,
|
||||
devices or fills. Shapes are drag-drawn with grid snap and a live preview;
|
||||
text is placed via a small dialog (size S/M/L, color; double-click to edit).
|
||||
Toolbar: Select (move, Delete key), Erase, color, three line widths and an
|
||||
optional 25% fill for rects/ovals. Esc walks back: draft → selection →
|
||||
Select tool → View.
|
||||
- The decor layer renders **under rooms** (a true underlay), is visible in all
|
||||
modes and completely click-transparent outside the editor. Stored per space
|
||||
in the server config (`space.decor`, validated on the backend, shared across
|
||||
clients with the usual rev/optimistic locking).
|
||||
|
||||
## v1.32.1 — 2026-07-22
|
||||
- Opening tool: hovering near a wall now shows a **dashed preview** of where
|
||||
the opening would land — snapped onto the wall, default door length (90 cm),
|
||||
with a center dot. No preview far from walls or over an existing opening
|
||||
(a click there edits it instead).
|
||||
|
||||
## v1.32.0 — 2026-07-22 (split polyline, tool cursors, Esc)
|
||||
- **Split can now cut along a polyline**, not just a straight chord: start on
|
||||
a wall, click intermediate points inside the room, finish on another wall.
|
||||
The path is validated (no wall crossings, no self-intersection) and drawn
|
||||
live with vertices and a preview segment. Two clicks still work as before.
|
||||
- **Tool cursors**: Merge and delete-room show a pointer; Split shows a
|
||||
pointer while picking the room, then a crosshair while cutting.
|
||||
- **Esc walks back out of Merge/Split** step by step: last cut point → first
|
||||
point → room selection → back to the Draw tool. Merge: selection → tool.
|
||||
|
||||
## v1.31.2 — 2026-07-22
|
||||
- Plan editor: the room picked with the **Merge** tool (and the room selected
|
||||
for **Split**) is highlighted amber again. The `.outlined` markup style,
|
||||
added later in the stylesheet, was silently overriding the `.picked`
|
||||
highlight at equal specificity (source-order gotcha #4 — rule order fixed
|
||||
and documented in the stylesheet).
|
||||
|
||||
## v1.31.1 — 2026-07-22
|
||||
- Plan editor: interacting with a room card (drag, corner-resize or a plain
|
||||
click) no longer leaks into the active markup tool — previously the click
|
||||
after a resize could add an outline point, pick a merge/split room or even
|
||||
prompt to delete the room under the card.
|
||||
|
||||
## v1.31.0 — 2026-07-22 (room cards)
|
||||
- Room labels grew into **room cards**: the name on top, and an optional
|
||||
smaller metrics line below — temperature, humidity, average Zigbee signal
|
||||
and lights, each behind its own checkbox in the space settings (all off by
|
||||
default). Lights show On/Off, or **"1 of 3"** when only part of the room is
|
||||
lit. Rooms without an HA area keep showing just the name.
|
||||
- Cards are **resizable** in the Plan editor: hovering shows corner handles;
|
||||
dragging one scales the whole card uniformly (0.5×–3×). The scale is stored
|
||||
in the layout next to the card position, and dragging keeps it.
|
||||
- Fixed a latent v1.25 regression: room labels were not rendered as draggable
|
||||
HTML in the Plan editor at all (only a static SVG name), so moving them was
|
||||
impossible. The Plan editor now renders real cards (name only) that can be
|
||||
dragged and resized.
|
||||
- Two markup smokes (merge/split) still called a method removed in v1.25 —
|
||||
repaired.
|
||||
|
||||
## v1.30.4 — 2026-07-22
|
||||
- **Escape now closes every dialog** (general settings, icon rules, device
|
||||
editor, space dialog, opening editor, info cards), topmost first when
|
||||
stacked. Closing the space dialog with Esc abandons a floor-import queue,
|
||||
same as its Cancel button. Esc while drawing an outline still undoes the
|
||||
last point (dialogs take priority).
|
||||
|
||||
## v1.30.3 — 2026-07-22
|
||||
- The **General settings** (fill palette) gear in the header is now visible in
|
||||
every mode for users who can edit, not just in the Plan editor.
|
||||
|
||||
## v1.30.2 — 2026-07-22 (editor tabs redesign)
|
||||
- Mode tabs renamed and reduced to two: **"Plan editor"** and **"Device
|
||||
editor"**. View is no longer a tab — it is the implicit default state.
|
||||
- The Device editor now has its own bottom toolbar (add / show all / reset
|
||||
layout / icon rules moved out of the header), mirroring the Plan toolbar.
|
||||
- Both toolbars and the active tab itself got an **X** button that closes the
|
||||
editor and returns to View. Re-clicking the active tab does nothing;
|
||||
switching Plan↔Devices is direct.
|
||||
|
||||
## v1.30.1 — 2026-07-22 (space gear polish)
|
||||
- The gear icon next to the space name is now visible in **every mode** (not
|
||||
just Plan) for users who can edit, so space settings are always one click
|
||||
away. The "+" (add space) tab remains Plan-only.
|
||||
- Fixed the gear's vertical alignment — it sat noticeably lower than the space
|
||||
name (baseline-aligned web component); tabs are now flex-centered.
|
||||
|
||||
## v1.30.0 — 2026-07-22 (lock action in the opening info card)
|
||||
- The door/window info card (View mode) now offers an explicit **Unlock/Lock
|
||||
button** when a lock entity is bound and available. Unlock is styled red as a
|
||||
security-sensitive action; the button is disabled during locking/unlocking and
|
||||
hidden when the lock is unavailable.
|
||||
- The security rule is untouched: **tapping a lock icon on the plan still never
|
||||
toggles it** — the action lives only behind a deliberate, clearly labeled
|
||||
button, same interaction contract as HA's more-info dialog.
|
||||
|
||||
## v1.29.0 — 2026-07-22 (the "new device" flag)
|
||||
- **Devices that appear in HA after installation no longer show up silently**:
|
||||
an auto-placed device (or light group) gets a big red dot at the top-right of
|
||||
its icon. The flag is stored server-side (`settings.new_device_ids`), so every
|
||||
client sees it — and it disappears everywhere the first time someone opens
|
||||
that device's editor.
|
||||
- The baseline of known devices is seeded silently on the first run after the
|
||||
update, so **existing devices never flood the plan with dots**. Hand-made
|
||||
markers (virtual/rebindings) are never flagged — the user just created them.
|
||||
(Pure `diffNewDevices`, unit-tested; backend schema for the two new settings.)
|
||||
|
||||
## v1.28.1 — 2026-07-22 (View-mode polish: cursors and inert openings)
|
||||
- **Device icons in View no longer show the grab cursor** (drag lives in the
|
||||
Devices mode); they show a pointer — clicking still works exactly as before.
|
||||
- **Doors and windows in View are pure drawings**: no grab cursor, no hover
|
||||
outline, no hit target, no click — regardless of what is bound to them.
|
||||
- **The lock badge is the one exception**: when a lock is bound, the badge is
|
||||
shown and clickable in View (pointer cursor, click opens the door/lock info
|
||||
card). It stays inert in Plan so it does not fight editing.
|
||||
- In Plan an opening remains fully interactive: grab cursor + hover outline,
|
||||
dragging along walls, and a click with ANY tool opens its properties dialog.
|
||||
|
||||
## v1.28.0 — 2026-07-21 (sub-area rooms: manual placement without an HA area; issue #3)
|
||||
- **Devices can now be placed into rooms that have no Home Assistant area.**
|
||||
The marker dialog's room list includes area-less rooms (marked "no area,
|
||||
manual"); pick one and the device/virtual marker lands at its centre. The
|
||||
marker stores `room_id`, so the choice survives edits and re-opens. The
|
||||
laundry-cupboard case from issue #3: a decorative room inside a larger area
|
||||
can now hold its own door sensor and light.
|
||||
- Room reference parsing extracted as pure `parseRoomRef` (`space#area` /
|
||||
`space#@roomId`), unit-tested; backend schema for `room_id`.
|
||||
|
||||
## v1.27.0 — 2026-07-21 (RGB light colors + alarm pulse; issue #3)
|
||||
- **RGB lights show their actual color**: an "on" light with a color tints its
|
||||
bulb icon, glow and — unless a custom ripple color is set — its presence ripple.
|
||||
Brightness is deliberately ignored (a dim red bulb still reads red); off,
|
||||
white-only and unavailable lights look as before. (Pure `lightColorOf`.)
|
||||
- **Emergencies pulse red**: leak / smoke / gas / CO / safety / tamper / problem
|
||||
binary sensors and sirens in `on` get a red pulsing ring over any display mode.
|
||||
`unavailable`/`unknown` never alarm — an outage is not a fire. Honours
|
||||
`prefers-reduced-motion`. (Pure `isAlarmState`.)
|
||||
|
||||
## v1.26.0 — 2026-07-21 (state-reflecting icons + value display; issue #3)
|
||||
- **Auto icons now reflect live state**, like core HA: door/window/garage sensors
|
||||
swap open↔closed variants, locks show locked/unlocked, a bulb lights up as
|
||||
|
||||
Executable
+364
@@ -0,0 +1,364 @@
|
||||
# История изменений
|
||||
|
||||
> Русская версия [docs/CHANGELOG.md](CHANGELOG.md). Переведены записи начиная
|
||||
> с v1.42.0 (2026-07-26); более ранние доступны только в английском файле.
|
||||
>
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## 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
+111
@@ -0,0 +1,111 @@
|
||||
# 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, curated 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).
|
||||
|
||||
## 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-27)
|
||||
|
||||
| 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.45.2** 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` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). 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.45.2** via direct copy (HACS custom repo also installed) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| Tests | 121 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
|
||||
| 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.
|
||||
|
||||
+386
-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 curation 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,310 @@ 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]
|
||||
- [ ] Curation 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]
|
||||
- [ ] 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 +491,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 +503,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 +527,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 +537,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 +553,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
+12
-12
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.23.2",
|
||||
"version": "1.41.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.23.2",
|
||||
"version": "1.41.2",
|
||||
"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": {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.26.0",
|
||||
"version": "1.45.2",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -16,7 +16,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"
|
||||
|
||||
+187
-11
@@ -2,7 +2,7 @@
|
||||
* Building the device list from HA registries: curation, light groups,
|
||||
* markers (overrides/virtual). No Lit/DOM — only the hass object.
|
||||
*/
|
||||
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule } from './rules';
|
||||
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule, EXCLUDED_DOMAINS } from './rules';
|
||||
import { averageLqi } from './logic';
|
||||
import type { DevItem, Marker, ServerConfig } from './types';
|
||||
|
||||
@@ -53,20 +53,33 @@ export function isTempEntity(hass: any, eid: string): boolean {
|
||||
}
|
||||
|
||||
export function primaryEntity(hass: any, entIds: string[], icon: string): string | undefined {
|
||||
const ents = entIds
|
||||
const all = entIds
|
||||
.map((eid) => ({ eid, reg: hass.entities[eid], st: hass.states[eid] }))
|
||||
.filter((e) => e.reg && !e.reg.hidden);
|
||||
const usable = ents.filter((e) => !e.reg.entity_category);
|
||||
const pool = usable.length ? usable : ents;
|
||||
.filter((e) => e.reg);
|
||||
// Tiers: visible primary entities first, but a HIDDEN light still beats a
|
||||
// visible config switch. Real case: individual lamps folded into a light
|
||||
// group get hidden in the registry — the device's main function is still
|
||||
// the lamp, and tap-toggle must flip IT, not the do-not-disturb switch.
|
||||
const tiers = [
|
||||
all.filter((e) => !e.reg.hidden && !e.reg.entity_category),
|
||||
all.filter((e) => !e.reg.hidden),
|
||||
all.filter((e) => !e.reg.entity_category),
|
||||
all,
|
||||
];
|
||||
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') {
|
||||
const t = pool.find((e) => isTempEntity(hass, e.eid));
|
||||
if (t) return t.eid;
|
||||
for (const tier of tiers) {
|
||||
const t = tier.find((e) => isTempEntity(hass, e.eid));
|
||||
if (t) return t.eid;
|
||||
}
|
||||
}
|
||||
for (const dom of DOMAIN_PRIORITY) {
|
||||
const found = pool.find((e) => e.eid.split('.')[0] === dom);
|
||||
if (found) return found.eid;
|
||||
for (const tier of tiers) {
|
||||
const found = tier.find((e) => e.eid.split('.')[0] === dom);
|
||||
if (found) return found.eid;
|
||||
}
|
||||
}
|
||||
return pool[0]?.eid;
|
||||
for (const tier of tiers) if (tier.length) return tier[0].eid;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Average zigbee LQI across the device's entities (*_linkquality/*_lqi sensors or an attribute). */
|
||||
@@ -149,7 +162,7 @@ export function lightGroups(hass: any, enabled: boolean): { eid: string; name: s
|
||||
}
|
||||
|
||||
/** Icon with the full fallback chain: name rules → entity device_class → chip. */
|
||||
function resolveIcon(hass: any, name: string, model: string | undefined, entIds: string[], rules?: CompiledIconRule[]): string {
|
||||
export function resolveIcon(hass: any, name: string, model: string | undefined, entIds: string[], rules?: CompiledIconRule[]): string {
|
||||
const byRules = iconFor(name, model, rules);
|
||||
if (byRules !== FALLBACK_ICON) return byRules;
|
||||
const classes: string[] = [];
|
||||
@@ -343,6 +356,169 @@ export function areaLights(hass: any, devices: { area: string; entities: string[
|
||||
return seen ? 'off' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit room measurement source: 'entity:<eid>' reads the state as a
|
||||
* number; 'device:<id>' aggregates over that device's entities (tempFor /
|
||||
* humFor). Used by the room-settings override (tier 3).
|
||||
*/
|
||||
export function sourceValue(hass: any, src: string | null | undefined, kind: 'temp' | 'hum'): number | null {
|
||||
if (!src) return null;
|
||||
const i = src.indexOf(':');
|
||||
if (i < 0) return null;
|
||||
const k = src.slice(0, i);
|
||||
const ref = src.slice(i + 1);
|
||||
if (!ref) return null;
|
||||
if (k === 'entity') {
|
||||
const v = parseFloat(hass.states[ref]?.state);
|
||||
if (!Number.isFinite(v)) return null;
|
||||
return kind === 'temp' ? Math.round(v * 10) / 10 : Math.round(v);
|
||||
}
|
||||
if (k === 'device') {
|
||||
const entIds = Object.entries(hass.entities as Record<string, any>)
|
||||
.filter(([, r]) => (r as any).device_id === ref)
|
||||
.map(([eid]) => eid);
|
||||
return kind === 'temp' ? tempFor(hass, entIds) : humFor(hass, entIds);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Average humidity across the area's climate-ish devices (integer %, or null). */
|
||||
export function areaHum(
|
||||
hass: any,
|
||||
devices: { area: string; icon?: string; entities: string[] }[],
|
||||
area: string,
|
||||
): number | null {
|
||||
const vals: number[] = [];
|
||||
for (const dv of devices) {
|
||||
if (dv.area !== area) continue;
|
||||
// same curation idea as areaTemp: climate sensors only, not fridges/plugs
|
||||
if (dv.icon !== 'mdi:thermometer' && dv.icon !== 'mdi:air-filter' && dv.icon !== 'mdi:water-percent') continue;
|
||||
const h = humFor(hass, dv.entities);
|
||||
if (h != null) vals.push(h);
|
||||
}
|
||||
if (!vals.length) return null;
|
||||
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity ids that measure something other than room air, however honest their
|
||||
* device_class is: water and coolant loops, chip/CPU/board temperatures,
|
||||
* battery temperature, setpoints (target/external) and the like.
|
||||
*/
|
||||
const NON_AIR_RE = new RegExp(
|
||||
[
|
||||
'water', 'voda', 'coolant', 'flow_?temp', 'return_?temp', 'target', 'setpoint',
|
||||
'chip', 'cpu', 'processor', 'board', 'core_temp', 'device_temp',
|
||||
'batter', 'akkum', 'freezer', 'fridge', 'oven', 'kettle', 'boiler',
|
||||
].join('|'),
|
||||
'i',
|
||||
);
|
||||
|
||||
/**
|
||||
* Room climate from EVERY sensor of the area — including devices that are not
|
||||
* placed on the plan (hidden by curation or by the user). The old helpers read
|
||||
* the visible-icon list, so hiding a thermometer silently removed it from the
|
||||
* room card (field report, 2026-07-27).
|
||||
*
|
||||
* Curation is kept: only devices the card itself recognises as thermometers /
|
||||
* air monitors count, so fridges, TRVs and chip-temperature plugs stay out.
|
||||
* The AUTO icon is used on purpose — a custom marker icon must not change what
|
||||
* a device measures.
|
||||
*/
|
||||
export interface AreaClimate { temp: number | null; hum: number | null }
|
||||
|
||||
/**
|
||||
* Climate for EVERY area in one registry pass (review R2-3).
|
||||
*
|
||||
* The per-area version below rescanned the whole registry for each room and
|
||||
* each measurement: with 60 rooms and 2000 entities that is 120 traversals per
|
||||
* render — an entire frame spent re-reading metadata that did not change. The
|
||||
* caller computes this map once per `hass` snapshot and looks rooms up in O(1).
|
||||
*/
|
||||
export function areaClimateMap(
|
||||
hass: any, rules?: CompiledIconRule[],
|
||||
): Map<string, AreaClimate> {
|
||||
const out = new Map<string, AreaClimate>();
|
||||
if (!hass?.entities) return out;
|
||||
// area -> device (or lone entity) -> the entities that belong to it
|
||||
const byArea = new Map<string, Map<string, { name: string; model?: string; ents: string[] }>>();
|
||||
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
|
||||
const dev = reg.device_id ? hass.devices?.[reg.device_id] : null;
|
||||
const area = reg.area_id || dev?.area_id || null;
|
||||
if (!area) continue;
|
||||
// Not every "temperature" is room air. Real finds on a live install: the
|
||||
// NAS processor temperature, the water in a smart kettle, a sauna heater at
|
||||
// 90 C and a virtual better_thermostat duplicating the real sensor (field
|
||||
// question, 2026-07-27). Three guards, cheapest first:
|
||||
if (reg.entity_category) continue; // diagnostic/config readings
|
||||
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // curated-out integrations
|
||||
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
|
||||
let groups = byArea.get(area);
|
||||
if (!groups) { groups = new Map(); byArea.set(area, groups); }
|
||||
const key = reg.device_id || eid;
|
||||
let g = groups.get(key);
|
||||
if (!g) {
|
||||
const st = hass.states?.[eid];
|
||||
g = {
|
||||
name: (dev ? dev.name_by_user || dev.name : reg.name || st?.attributes?.friendly_name || eid) || eid,
|
||||
model: dev?.model,
|
||||
ents: [],
|
||||
};
|
||||
groups.set(key, g);
|
||||
}
|
||||
g.ents.push(eid);
|
||||
}
|
||||
for (const [area, groups] of byArea) {
|
||||
const temps: number[] = [];
|
||||
const hums: number[] = [];
|
||||
for (const g of groups.values()) {
|
||||
const icon = resolveIcon(hass, g.name, g.model, g.ents, rules);
|
||||
const air = icon === 'mdi:thermometer' || icon === 'mdi:air-filter';
|
||||
if (air) {
|
||||
const t = tempFor(hass, g.ents);
|
||||
if (t != null) temps.push(t);
|
||||
}
|
||||
if (air || icon === 'mdi:water-percent') {
|
||||
const h = humFor(hass, g.ents);
|
||||
if (h != null) hums.push(h);
|
||||
}
|
||||
}
|
||||
if (!temps.length && !hums.length) continue;
|
||||
out.set(area, {
|
||||
temp: temps.length ? Math.round((temps.reduce((a, b) => a + b, 0) / temps.length) * 10) / 10 : null,
|
||||
hum: hums.length ? Math.round(hums.reduce((a, b) => a + b, 0) / hums.length) : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One area's reading. Convenience wrapper — prefer the map for many areas. */
|
||||
export function areaClimate(
|
||||
hass: any, area: string, kind: 'temp' | 'hum', rules?: CompiledIconRule[],
|
||||
): number | null {
|
||||
if (!area) return null;
|
||||
return areaClimateMap(hass, rules).get(area)?.[kind] ?? null;
|
||||
}
|
||||
|
||||
/** How many of the area's lights are on: {on, total}, or null without lights. */
|
||||
export function areaLightStats(
|
||||
hass: any,
|
||||
devices: { area: string; entities: string[] }[],
|
||||
area: string,
|
||||
): { on: number; total: number } | null {
|
||||
const seen = new Set<string>();
|
||||
let on = 0;
|
||||
for (const dv of devices) {
|
||||
if (dv.area !== area) continue;
|
||||
for (const eid of dv.entities) {
|
||||
if (!eid.startsWith('light.') || seen.has(eid)) continue;
|
||||
seen.add(eid);
|
||||
if (hass.states[eid]?.state === 'on') on++;
|
||||
}
|
||||
}
|
||||
return seen.size ? { on, total: seen.size } : null;
|
||||
}
|
||||
|
||||
/** Average temperature across the area's devices (null when nothing reports one). */
|
||||
/** Average zigbee signal (LQI) across an area's non-virtual devices, or null. */
|
||||
export function areaLqi(hass: any, devices: { area: string; virtual?: boolean; entities: string[] }[], area: string): number | null {
|
||||
|
||||
+4
-14
@@ -63,23 +63,12 @@ class HouseplanCardEditor extends LitElement {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tap_action',
|
||||
selector: {
|
||||
select: {
|
||||
mode: 'dropdown',
|
||||
options: [
|
||||
{ value: 'info', label: t(L, 'tap.info') },
|
||||
{ value: 'more-info', label: t(L, 'tap.more_info') },
|
||||
{ value: 'toggle', label: t(L, 'tap.toggle') },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
|
||||
{ name: 'show_temperature', selector: { boolean: {} } },
|
||||
{ name: 'live_states', selector: { boolean: {} } },
|
||||
{ name: 'show_signal', selector: { boolean: {} } },
|
||||
{ name: 'kiosk', selector: { boolean: {} } },
|
||||
{ name: 'cycle', selector: { number: { min: 0, max: 3600, step: 5, mode: 'box' } } },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -91,11 +80,12 @@ class HouseplanCardEditor extends LitElement {
|
||||
title: t(L, 'editor.title'),
|
||||
default_floor: t(L, 'editor.default_floor'),
|
||||
language: t(L, 'editor.language'),
|
||||
tap_action: t(L, 'editor.tap_action'),
|
||||
icon_size: t(L, 'editor.icon_size'),
|
||||
show_temperature: t(L, 'editor.show_temperature'),
|
||||
live_states: t(L, 'editor.live_states'),
|
||||
show_signal: t(L, 'editor.show_signal'),
|
||||
kiosk: t(L, 'editor.kiosk'),
|
||||
cycle: t(L, 'editor.cycle'),
|
||||
};
|
||||
return html`<ha-form
|
||||
.hass=${this.hass}
|
||||
|
||||
+2164
-201
File diff suppressed because it is too large
Load Diff
+98
-13
@@ -22,7 +22,6 @@
|
||||
"title.zoom_reset": "Reset zoom",
|
||||
"title.add_device": "Add a device to the plan",
|
||||
"title.show_all": "Show all area devices (no curation)",
|
||||
"title.reset_layout": "Reset icon positions to auto layout",
|
||||
"title.markup": "Room markup: grid, lines, outlines",
|
||||
"title.configure_space": "Configure space",
|
||||
"title.add_space": "Add space",
|
||||
@@ -60,7 +59,6 @@
|
||||
"markup.delete": "Delete",
|
||||
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
|
||||
"markup.hint_start": "click a grid dot to start the outline",
|
||||
"tip.room": "room — open the area",
|
||||
"tip.lqi": "average zigbee signal:",
|
||||
"info.device_header": "Device on the plan",
|
||||
"info.model": "Model",
|
||||
@@ -115,7 +113,6 @@
|
||||
"device.light_group": "light group",
|
||||
"device.fallback": "device",
|
||||
"device.virtual": "virtual device",
|
||||
"confirm.reset_layout": "Reset all icon positions to the auto layout?",
|
||||
"confirm.delete_room": "Delete room \"{name}\"?",
|
||||
"confirm.remove_marker": "Remove \"{name}\" from the plan?",
|
||||
"confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?",
|
||||
@@ -124,12 +121,11 @@
|
||||
"toast.markup_needs_server": "Markup is available after the config is moved to the server",
|
||||
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
|
||||
"toast.cfg_save_failed": "Failed to save config: {err}",
|
||||
"toast.point_in_room": "That point is inside room “{name}” — rooms must not overlap",
|
||||
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
|
||||
"toast.merge_not_adjacent": "Only rooms that share a wall can be merged",
|
||||
"toast.rooms_merged": "Rooms merged into “{name}”",
|
||||
"toast.split_pick_wall": "Click a grid dot on the room’s wall",
|
||||
"toast.split_bad_cut": "The cut must be a straight line from wall to wall, inside the room",
|
||||
"toast.split_pick_wall": "Start the cut on the room’s wall",
|
||||
"toast.split_bad_cut": "The cut must run wall to wall inside the room, without crossing walls or itself",
|
||||
"merge.header": "Merge rooms",
|
||||
"merge.hint": "The merged room keeps one name and one area. The other area is released — its devices leave the plan until another room claims it.",
|
||||
"merge.keep": "Keep",
|
||||
@@ -179,11 +175,9 @@
|
||||
"rules.saved": "Icon rules saved",
|
||||
"btn.up": "Up",
|
||||
"btn.down": "Down",
|
||||
"editor.tap_action": "Tap on a device",
|
||||
"tap.info": "Info card",
|
||||
"tap.info": "Device card",
|
||||
"tap.more_info": "HA more-info dialog",
|
||||
"tap.toggle": "Toggle (lights/switches)",
|
||||
"tap.auto": "As the card default",
|
||||
"marker.tap_label": "Tap action for this device",
|
||||
"tap.toggle_note": "Toggle never applies to locks and alarms; hold the icon to open the info card.",
|
||||
"import.title": "Create spaces from HA floors",
|
||||
@@ -240,8 +234,99 @@
|
||||
"gs.saved": "General settings saved",
|
||||
"space.show_lqi": "Show zigbee signal (LQI) next to devices",
|
||||
"gs.light_none": "No light sources",
|
||||
"mode.view": "View",
|
||||
"mode.plan": "Plan",
|
||||
"mode.devices": "Devices",
|
||||
"display.value": "Value instead of an icon"
|
||||
"mode.plan": "Plan editor",
|
||||
"mode.devices": "Device editor",
|
||||
"display.value": "Value instead of an icon",
|
||||
"marker.subarea": "no area, manual",
|
||||
"device.new": "New device — open its editor to dismiss",
|
||||
"opening.unlock_action": "Unlock",
|
||||
"opening.lock_action": "Lock",
|
||||
"opening.lock_pending": "Working…",
|
||||
"title.close_editor": "Close editor (back to view)",
|
||||
"devbar.add": "Add",
|
||||
"devbar.show_all": "Show all",
|
||||
"devbar.rules": "Icon rules",
|
||||
"space.roomcard_section": "Room card shows:",
|
||||
"space.label_temp": "Temperature",
|
||||
"space.label_hum": "Humidity",
|
||||
"space.label_lqi": "Average Zigbee signal",
|
||||
"space.label_light": "Lights on/off",
|
||||
"roomcard.light_on": "On",
|
||||
"roomcard.light_off": "Off",
|
||||
"roomcard.light_partial": "{on} of {total}",
|
||||
"toast.split_pick_inside": "Intermediate cut points must be inside the room",
|
||||
"mode.decor": "Background editor",
|
||||
"decor.select": "Select",
|
||||
"decor.line": "Line",
|
||||
"decor.rect": "Rectangle",
|
||||
"decor.ellipse": "Oval",
|
||||
"decor.text": "Text",
|
||||
"decor.erase": "Erase",
|
||||
"decor.color": "Color",
|
||||
"decor.width": "Line width",
|
||||
"decor.w_thin": "Thin",
|
||||
"decor.w_mid": "Medium",
|
||||
"decor.w_thick": "Thick",
|
||||
"decor.fill": "Fill",
|
||||
"decor.text_title": "Text label",
|
||||
"decor.text_label": "Text",
|
||||
"decor.text_size": "Size",
|
||||
"decor.size_s": "Small",
|
||||
"decor.size_m": "Medium",
|
||||
"decor.size_l": "Large",
|
||||
"marker.icon_auto": "Auto: {icon} (by icon rules; pick one to override)",
|
||||
"mode.plan_tip": "Plan editor — the geometry of the home: draw and split/merge rooms, bind them to HA areas, place doors and windows, move room cards, set the scale",
|
||||
"mode.devices_tip": "Device editor — everything about icons: drag to position, click to edit binding/icon/display, add virtual devices, icon rules",
|
||||
"mode.decor_tip": "Background editor — purely visual decor under the plan: lines, rectangles, ovals and text labels that never react to clicks",
|
||||
"fill.glow": "Light sources (dark house, glowing lamps)",
|
||||
"gs.glow_group": "Light-sources fill",
|
||||
"gs.glow_base": "House darkness",
|
||||
"gs.glow_light": "Default light color / intensity",
|
||||
"gs.glow_radius": "Glow radius",
|
||||
"gs.unit_m": "m",
|
||||
"gs.unit_ft": "ft",
|
||||
"marker.controls_label": "Controls light sources",
|
||||
"marker.controls_hint": "With tap action “Toggle”, a click flips all bound lights at once (any on → all off). The icon mirrors their state.",
|
||||
"marker.controls_filter": "Search lights and switches…",
|
||||
"info.controls": "Controls",
|
||||
"marker.glow_radius_label": "Glow radius (light-sources fill)",
|
||||
"marker.glow_radius_hint": "empty = default from general settings",
|
||||
"markup.openwall": "Open boundary",
|
||||
"title.markup_openwall": "Open boundary — click a wall shared by two rooms to make it virtual: light flows through, the line turns dashed. Click again to close it.",
|
||||
"toast.openwall_pick": "Click a wall shared by two rooms",
|
||||
"toast.openwall_opened": "Boundary “{a}” ↔ “{b}” is now open",
|
||||
"toast.openwall_closed": "Boundary “{a}” ↔ “{b}” is closed again",
|
||||
"marker.from_ha_option": "Pick from the HA list",
|
||||
"marker.show_entities": "Show entities",
|
||||
"marker.show_entities_tip": "Adds not only devices to the list, but all their entities too",
|
||||
"marker.pick_ph": "Choose a device…",
|
||||
"room.open_area": "Open the HA area",
|
||||
"kiosk.title": "This screen's sizes",
|
||||
"kiosk.hint": "Stored on this device only — every wall tablet or TV can have its own comfortable sizes.",
|
||||
"kiosk.icon_scale": "Device icon size",
|
||||
"kiosk.font_scale": "Room card text size",
|
||||
"editor.kiosk": "Wall device (kiosk) mode",
|
||||
"editor.cycle": "Auto-switch spaces every N seconds (kiosk, 0 = off)",
|
||||
"room.settings_title": "Room settings",
|
||||
"room.settings_section": "Room settings (override the space)",
|
||||
"room.fill_label": "Fill in THIS room",
|
||||
"fill.inherit": "As the space",
|
||||
"room.temp_src_label": "Temperature source",
|
||||
"room.hum_src_label": "Humidity source",
|
||||
"room.src_average": "Average over the room's sensors (default)",
|
||||
"room.src_pick": "A specific HA device or entity",
|
||||
"room.src_ph": "Choose a source…",
|
||||
"toast.room_updated": "Room updated",
|
||||
"space.card_font": "Room-card font size (whole space)",
|
||||
"room.sizes_section": "Font sizes",
|
||||
"room.name_scale": "Room name size",
|
||||
"room.label_scale": "Metrics size",
|
||||
"preview.room_name": "Living room",
|
||||
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}",
|
||||
"room.settings_short": "Room",
|
||||
"room.unnamed": "Unnamed room",
|
||||
"marker.is_light": "This device is a light source",
|
||||
"marker.is_light_tip": "Makes the icon glow in the “Light sources” fill even without a light entity — for a smart switch driving ordinary fixtures. The glow follows the switch (or the lights bound above).",
|
||||
"confirm.unlock": "Unlock “{name}”?",
|
||||
"toast.files_migrate_failed": "Attachments could not be moved to the new binding, links keep pointing at the old files: {err}"
|
||||
}
|
||||
|
||||
+99
-14
@@ -22,7 +22,6 @@
|
||||
"title.zoom_reset": "Сбросить масштаб",
|
||||
"title.add_device": "Добавить устройство на план",
|
||||
"title.show_all": "Показывать все устройства зоны (без курирования)",
|
||||
"title.reset_layout": "Сбросить позиции значков к авто-раскладке",
|
||||
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
@@ -60,7 +59,6 @@
|
||||
"markup.delete": "Удалить",
|
||||
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
|
||||
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
|
||||
"tip.room": "комната — открыть зону",
|
||||
"tip.lqi": "средний сигнал zigbee:",
|
||||
"info.device_header": "Устройство на плане",
|
||||
"info.model": "Модель",
|
||||
@@ -115,7 +113,6 @@
|
||||
"device.light_group": "группа света",
|
||||
"device.fallback": "устройство",
|
||||
"device.virtual": "виртуальное устройство",
|
||||
"confirm.reset_layout": "Сбросить позиции всех иконок к авто-раскладке?",
|
||||
"confirm.delete_room": "Удалить комнату «{name}»?",
|
||||
"confirm.remove_marker": "Убрать «{name}» с плана?",
|
||||
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
|
||||
@@ -124,12 +121,11 @@
|
||||
"toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер",
|
||||
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
|
||||
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
|
||||
"toast.point_in_room": "Точка внутри комнаты «{name}» — комнаты не должны накладываться",
|
||||
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
|
||||
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
|
||||
"toast.rooms_merged": "Комнаты объединены в «{name}»",
|
||||
"toast.split_pick_wall": "Кликните по узлу сетки на стене комнаты",
|
||||
"toast.split_bad_cut": "Разрез — прямая от стены до стены внутри комнаты",
|
||||
"toast.split_pick_wall": "Начните разрез на стене комнаты",
|
||||
"toast.split_bad_cut": "Разрез — от стены до стены внутри комнаты, без пересечения стен и самого себя",
|
||||
"merge.header": "Объединение комнат",
|
||||
"merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.",
|
||||
"merge.keep": "Оставить",
|
||||
@@ -179,12 +175,10 @@
|
||||
"rules.saved": "Правила иконок сохранены",
|
||||
"btn.up": "Вверх",
|
||||
"btn.down": "Вниз",
|
||||
"editor.tap_action": "Тап по устройству",
|
||||
"tap.info": "Инфо-карточка",
|
||||
"tap.info": "Карточка устройства",
|
||||
"tap.more_info": "Диалог HA (more-info)",
|
||||
"tap.toggle": "Переключить (свет/розетки)",
|
||||
"tap.auto": "Как в настройках карточки",
|
||||
"marker.tap_label": "Действие по тапу для этого устройства",
|
||||
"marker.tap_label": "Действие по нажатию для этого устройства",
|
||||
"tap.toggle_note": "Toggle никогда не применяется к замкам и сигнализациям; долгое нажатие всегда открывает инфо-карточку.",
|
||||
"import.title": "Создать пространства из этажей HA",
|
||||
"import.hint": "Home Assistant уже знает эти этажи. Отметьте, какие превратить в пространства плана — далее для каждого попросим картинку плана. Комнаты затем обводятся вручную по плану.",
|
||||
@@ -240,8 +234,99 @@
|
||||
"gs.saved": "Общие настройки сохранены",
|
||||
"space.show_lqi": "Показывать зигби-сигнал (LQI) у устройств",
|
||||
"gs.light_none": "Нет источников света",
|
||||
"mode.view": "Просмотр",
|
||||
"mode.plan": "План",
|
||||
"mode.devices": "Устройства",
|
||||
"display.value": "Значение вместо иконки"
|
||||
"mode.plan": "Редактор плана",
|
||||
"mode.devices": "Редактор устройств",
|
||||
"display.value": "Значение вместо иконки",
|
||||
"marker.subarea": "без зоны, вручную",
|
||||
"device.new": "Новое устройство — откройте его редактор, чтобы снять отметку",
|
||||
"opening.unlock_action": "Открыть замок",
|
||||
"opening.lock_action": "Закрыть замок",
|
||||
"opening.lock_pending": "Выполняется…",
|
||||
"title.close_editor": "Закрыть редактор (вернуться к просмотру)",
|
||||
"devbar.add": "Добавить",
|
||||
"devbar.show_all": "Показать все",
|
||||
"devbar.rules": "Правила иконок",
|
||||
"space.roomcard_section": "В карточке комнаты:",
|
||||
"space.label_temp": "Температура",
|
||||
"space.label_hum": "Влажность",
|
||||
"space.label_lqi": "Средний Zigbee-сигнал",
|
||||
"space.label_light": "Свет вкл/выкл",
|
||||
"roomcard.light_on": "Вкл",
|
||||
"roomcard.light_off": "Выкл",
|
||||
"roomcard.light_partial": "{on} из {total}",
|
||||
"toast.split_pick_inside": "Промежуточные точки разреза — внутри комнаты",
|
||||
"mode.decor": "Редактор подложки",
|
||||
"decor.select": "Выбрать",
|
||||
"decor.line": "Линия",
|
||||
"decor.rect": "Прямоугольник",
|
||||
"decor.ellipse": "Овал",
|
||||
"decor.text": "Надпись",
|
||||
"decor.erase": "Стереть",
|
||||
"decor.color": "Цвет",
|
||||
"decor.width": "Толщина линии",
|
||||
"decor.w_thin": "Тонкая",
|
||||
"decor.w_mid": "Средняя",
|
||||
"decor.w_thick": "Толстая",
|
||||
"decor.fill": "Залить",
|
||||
"decor.text_title": "Надпись",
|
||||
"decor.text_label": "Текст",
|
||||
"decor.text_size": "Размер",
|
||||
"decor.size_s": "Мелкий",
|
||||
"decor.size_m": "Средний",
|
||||
"decor.size_l": "Крупный",
|
||||
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
|
||||
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
|
||||
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
|
||||
"mode.decor_tip": "Редактор подложки — чисто визуальный декор под планом: линии, прямоугольники, овалы и надписи, не реагирующие на клики",
|
||||
"fill.glow": "Свет по источникам (тёмный дом, пятна света)",
|
||||
"gs.glow_group": "Заливка «Свет по источникам»",
|
||||
"gs.glow_base": "Темнота дома",
|
||||
"gs.glow_light": "Цвет света по умолчанию / интенсивность",
|
||||
"gs.glow_radius": "Радиус свечения",
|
||||
"gs.unit_m": "м",
|
||||
"gs.unit_ft": "фут",
|
||||
"marker.controls_label": "Управляет источниками света",
|
||||
"marker.controls_hint": "При действии «Переключить» клик разом переключает все привязанные источники (горит хоть один → выключить все). Значок отражает их состояние.",
|
||||
"marker.controls_filter": "Поиск ламп и выключателей…",
|
||||
"info.controls": "Управляет",
|
||||
"marker.glow_radius_label": "Радиус свечения (заливка «Свет по источникам»)",
|
||||
"marker.glow_radius_hint": "пусто = по умолчанию из общих настроек",
|
||||
"markup.openwall": "Открытая граница",
|
||||
"title.markup_openwall": "Открытая граница — клик по общей стене двух комнат делает её условной: свет проходит насквозь, линия становится пунктирной. Повторный клик закрывает.",
|
||||
"toast.openwall_pick": "Кликните по стене, разделяющей две комнаты",
|
||||
"toast.openwall_opened": "Граница «{a}» ↔ «{b}» теперь открыта",
|
||||
"toast.openwall_closed": "Граница «{a}» ↔ «{b}» снова закрыта",
|
||||
"marker.from_ha_option": "Выбрать из списка HA",
|
||||
"marker.show_entities": "Отображать сущности",
|
||||
"marker.show_entities_tip": "Добавляет в список не только устройства, но и все их сущности",
|
||||
"marker.pick_ph": "Выберите устройство…",
|
||||
"room.open_area": "Открыть зону в HA",
|
||||
"kiosk.title": "Размеры на этом экране",
|
||||
"kiosk.hint": "Хранится только на этом устройстве — у каждого настенного планшета или ТВ свои удобные размеры.",
|
||||
"kiosk.icon_scale": "Размер значков устройств",
|
||||
"kiosk.font_scale": "Размер текста карточек комнат",
|
||||
"editor.kiosk": "Режим настенного устройства (киоск)",
|
||||
"editor.cycle": "Автосмена пространств каждые N секунд (киоск, 0 = выкл)",
|
||||
"room.settings_title": "Настройки комнаты",
|
||||
"room.settings_section": "Настройки комнаты (переопределяют пространство)",
|
||||
"room.fill_label": "Заливка в ЭТОЙ комнате",
|
||||
"fill.inherit": "Как у пространства",
|
||||
"room.temp_src_label": "Источник температуры",
|
||||
"room.hum_src_label": "Источник влажности",
|
||||
"room.src_average": "Средняя по датчикам комнаты (по умолчанию)",
|
||||
"room.src_pick": "Конкретное устройство или сущность HA",
|
||||
"room.src_ph": "Выберите источник…",
|
||||
"toast.room_updated": "Комната обновлена",
|
||||
"space.card_font": "Размер шрифта карточек комнат (всё пространство)",
|
||||
"room.sizes_section": "Размеры шрифтов",
|
||||
"room.name_scale": "Размер названия",
|
||||
"room.label_scale": "Размер подписей",
|
||||
"preview.room_name": "Гостиная",
|
||||
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
|
||||
"room.settings_short": "Комната",
|
||||
"room.unnamed": "Комната без имени",
|
||||
"marker.is_light": "Это устройство — источник света",
|
||||
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
|
||||
"confirm.unlock": "Открыть замок «{name}»?",
|
||||
"toast.files_migrate_failed": "Не удалось перенести вложения к новой привязке, ссылки остались на старые файлы: {err}"
|
||||
}
|
||||
|
||||
+623
-24
@@ -38,8 +38,14 @@ export function formatLength(cm: number, imperial: boolean): string {
|
||||
* normalized (0..1) coordinates need more (see roomEdges).
|
||||
*/
|
||||
export function segKey(a: number[], b: number[], prec = 1): string {
|
||||
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
|
||||
return `${p[0].toFixed(prec)},${p[1].toFixed(prec)}-${q[0].toFixed(prec)},${q[1].toFixed(prec)}`;
|
||||
// audit G3: ROUND FIRST, then order. Ordering on raw floats while printing
|
||||
// rounded ones let one wall produce two different keys, so shared walls were
|
||||
// emitted twice and the dedup invariant quietly broke.
|
||||
const ax = a[0].toFixed(prec), ay = a[1].toFixed(prec);
|
||||
const bx = b[0].toFixed(prec), by = b[1].toFixed(prec);
|
||||
const first = ax < bx || (ax === bx && ay <= by);
|
||||
const [px, py, qx, qy] = first ? [ax, ay, bx, by] : [bx, by, ax, ay];
|
||||
return `${px},${py}-${qx},${qy}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,6 +230,36 @@ export function segmentsProperlyCross(
|
||||
}
|
||||
|
||||
/** Is any area of outline `a` strictly inside `b`? Also catches nested and duplicate outlines. */
|
||||
/**
|
||||
* A point guaranteed to lie strictly inside the polygon (audit G2).
|
||||
* The arithmetic mean of the vertices lies OUTSIDE concave shapes (U/L rooms
|
||||
* are common in hand-drawn plans), which made containment tests misfire.
|
||||
* Strategy: try the midpoints of the diagonals from each vertex, then a
|
||||
* triangle centroid of consecutive vertices — the first point that passes
|
||||
* pointStrictlyInside wins.
|
||||
*/
|
||||
export function interiorPoint(poly: number[][], eps = 1e-6): number[] | null {
|
||||
if (!poly || poly.length < 3) return null;
|
||||
const n = poly.length;
|
||||
const mean = [
|
||||
poly.reduce((s, p) => s + p[0], 0) / n,
|
||||
poly.reduce((s, p) => s + p[1], 0) / n,
|
||||
];
|
||||
if (pointStrictlyInside(mean, poly, eps)) return mean;
|
||||
for (let i = 0; i < n; i++) {
|
||||
// centroid of the ear at vertex i
|
||||
const a = poly[(i - 1 + n) % n], b = poly[i], c = poly[(i + 1) % n];
|
||||
const cand = [(a[0] + b[0] + c[0]) / 3, (a[1] + b[1] + c[1]) / 3];
|
||||
if (pointStrictlyInside(cand, poly, eps)) return cand;
|
||||
}
|
||||
for (let i = 0; i < n; i++)
|
||||
for (let j = i + 2; j < n; j++) {
|
||||
const cand = [(poly[i][0] + poly[j][0]) / 2, (poly[i][1] + poly[j][1]) / 2];
|
||||
if (pointStrictlyInside(cand, poly, eps)) return cand;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
||||
let allOnBoundary = true;
|
||||
for (const v of a) {
|
||||
@@ -232,28 +268,51 @@ function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
||||
}
|
||||
// every vertex sits on b's outline → a duplicate or traced outline: probe the middle
|
||||
if (allOnBoundary) {
|
||||
const c = [
|
||||
a.reduce((s, p) => s + p[0], 0) / a.length,
|
||||
a.reduce((s, p) => s + p[1], 0) / a.length,
|
||||
];
|
||||
return pointStrictlyInside(c, b, eps);
|
||||
const c = interiorPoint(a, eps); // audit G2: NOT the vertex mean
|
||||
return !!c && pointStrictlyInside(c, b, eps);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Is `inner` fully contained in `outer` (edges may touch, never cross)? */
|
||||
export function polyContainsPoly(outer: number[][], inner: number[][], eps = 1e-6): boolean {
|
||||
if (!outer || !inner || outer.length < 3 || inner.length < 3) return false;
|
||||
for (let i = 0; i < inner.length; i++)
|
||||
for (let j = 0; j < outer.length; j++)
|
||||
if (segmentsProperlyCross(inner[i], inner[(i + 1) % inner.length], outer[j], outer[(j + 1) % outer.length]))
|
||||
return false;
|
||||
for (const v of inner)
|
||||
if (!pointStrictlyInside(v, outer, eps) && !pointOnBoundary(v, outer, eps)) return false;
|
||||
// identical/traced outlines are NOT containment — probe a real interior point
|
||||
// (audit G2: the vertex mean lies outside concave rooms)
|
||||
const c = interiorPoint(inner, eps);
|
||||
return !!c && pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two room outlines share floor area? Rooms must never overlap, but sharing a wall
|
||||
* (fully or partially) and touching at a corner are normal and stay legal. A room nested
|
||||
* inside another counts as an overlap.
|
||||
* Do two room outlines ILLEGALLY share floor area? Sharing a wall (fully or
|
||||
* partially) and touching at a corner are normal. Since v1.34.0 full nesting is
|
||||
* legal too (island rooms: a column inside a ring, an inner room) — only edge
|
||||
* crossings and PARTIAL overlaps are rejected.
|
||||
*/
|
||||
export function roomsOverlap(a: number[][], b: number[][], eps = 1e-6): boolean {
|
||||
if (!a || !b || a.length < 3 || b.length < 3) return false;
|
||||
for (let i = 0; i < a.length; i++)
|
||||
for (let j = 0; j < b.length; j++)
|
||||
if (segmentsProperlyCross(a[i], a[(i + 1) % a.length], b[j], b[(j + 1) % b.length])) return true;
|
||||
if (polyContainsPoly(a, b, eps) || polyContainsPoly(b, a, eps)) return false;
|
||||
return coversArea(a, b, eps) || coversArea(b, a, eps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct islands of `poly` among `others`: outlines fully inside it that are not
|
||||
* themselves inside a bigger island (those are subtracted by their parent).
|
||||
*/
|
||||
export function islandsOf(poly: number[][], others: number[][][], eps = 1e-6): number[][][] {
|
||||
const inside = others.filter((o) => polyContainsPoly(poly, o, eps));
|
||||
return inside.filter((o) => !inside.some((p) => p !== o && polyContainsPoly(p, o, eps)));
|
||||
}
|
||||
|
||||
/** Shoelace area of an outline (absolute value). */
|
||||
export function polygonArea(poly: number[][]): number {
|
||||
if (!poly || poly.length < 3) return 0;
|
||||
@@ -310,29 +369,82 @@ function dropRepeats(pts: number[][], eps: number): number[][] {
|
||||
export function splitRoom(
|
||||
poly: number[][], a: number[], b: number[], eps = 1e-6,
|
||||
): [number[][], number[][]] | null {
|
||||
if (!poly || poly.length < 3 || samePoint(a, b, eps)) return null;
|
||||
return splitRoomPath(poly, [a, b], eps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a room along a polyline: first and last points on walls, intermediate
|
||||
* vertices strictly inside the room. A two-point path is the classic straight
|
||||
* chord. Returns the two parts, or null when the path is not a clean cut.
|
||||
*/
|
||||
export function splitRoomPath(
|
||||
poly: number[][], pts: number[][], eps = 1e-6,
|
||||
): [number[][], number[][]] | null {
|
||||
if (!poly || poly.length < 3 || !pts || pts.length < 2) return null;
|
||||
const a = pts[0];
|
||||
const b = pts[pts.length - 1];
|
||||
if (samePoint(a, b, eps)) return null;
|
||||
const ia = edgeIndexOf(poly, a, eps);
|
||||
const ib = edgeIndexOf(poly, b, eps);
|
||||
if (ia < 0 || ib < 0) return null; // an end is not on a wall
|
||||
for (let i = 0; i < poly.length; i++)
|
||||
if (segmentsProperlyCross(a, b, poly[i], poly[(i + 1) % poly.length])) return null; // leaves the room
|
||||
// a chord lying along a wall has its midpoint ON the outline, not inside it
|
||||
if (!pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps)) return null;
|
||||
const mids = pts.slice(1, -1);
|
||||
for (const m of mids) if (!pointStrictlyInside(m, poly, eps)) return null;
|
||||
// no path segment may cross a wall
|
||||
for (let sI = 0; sI < pts.length - 1; sI++)
|
||||
for (let i = 0; i < poly.length; i++)
|
||||
if (segmentsProperlyCross(pts[sI], pts[sI + 1], poly[i], poly[(i + 1) % poly.length])) return null;
|
||||
// the path may not properly self-intersect
|
||||
for (let sI = 0; sI < pts.length - 1; sI++)
|
||||
for (let t = sI + 2; t < pts.length - 1; t++)
|
||||
if (segmentsProperlyCross(pts[sI], pts[sI + 1], pts[t], pts[t + 1])) return null;
|
||||
// a straight chord lying along a wall has its midpoint ON the outline, not inside
|
||||
if (pts.length === 2 && !pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps))
|
||||
return null;
|
||||
const walk = (from: number[], fromIdx: number, to: number[], toIdx: number): number[][] => {
|
||||
const pts: number[][] = [from];
|
||||
const acc: number[][] = [from];
|
||||
let i = (fromIdx + 1) % poly.length;
|
||||
for (let guard = 0; guard <= poly.length; guard++) {
|
||||
pts.push(poly[i]);
|
||||
acc.push(poly[i]);
|
||||
if (i === toIdx) break;
|
||||
i = (i + 1) % poly.length;
|
||||
}
|
||||
pts.push(to);
|
||||
return dropRepeats(pts, eps);
|
||||
acc.push(to);
|
||||
return dropRepeats(acc, eps);
|
||||
};
|
||||
const p1 = walk(a, ia, b, ib);
|
||||
const p2 = walk(b, ib, a, ia);
|
||||
let p1: number[][];
|
||||
let p2: number[][];
|
||||
if (ia === ib) {
|
||||
// BOTH ends on the SAME edge — carving an alcove out of one wall. The walk
|
||||
// above would traverse the whole outline twice and return two overlapping,
|
||||
// self-intersecting rooms whose areas sum to 2x the original (audit G1,
|
||||
// 2026-07-27). The niche is simply the path closed along that edge; the
|
||||
// remainder is the outline with that stretch replaced by the path.
|
||||
const niche = dropRepeats([...pts], eps);
|
||||
if (niche.length < 3 || polygonArea(niche) <= eps) return null;
|
||||
// the niche must not swallow other geometry: it stays inside the room
|
||||
const rest: number[][] = [];
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
rest.push(poly[i]);
|
||||
if (i === ia) {
|
||||
// walk the cut from a to b along the edge direction
|
||||
const dir = (poly[(ia + 1) % poly.length][0] - poly[ia][0]) * (b[0] - a[0])
|
||||
+ (poly[(ia + 1) % poly.length][1] - poly[ia][1]) * (b[1] - a[1]);
|
||||
const path = dir >= 0 ? pts : [...pts].reverse();
|
||||
for (const p of path) rest.push(p);
|
||||
}
|
||||
}
|
||||
p1 = dropRepeats(rest, eps);
|
||||
p2 = niche;
|
||||
} else {
|
||||
p1 = dropRepeats([...walk(a, ia, b, ib), ...[...mids].reverse()], eps);
|
||||
p2 = dropRepeats([...walk(b, ib, a, ia), ...mids], eps);
|
||||
}
|
||||
if (p1.length < 3 || p2.length < 3) return null;
|
||||
if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null;
|
||||
// INVARIANT (audit G1): a split partitions the room — the parts must sum to
|
||||
// the original. Anything else means the walk produced overlapping garbage.
|
||||
if (Math.abs(polygonArea(p1) + polygonArea(p2) - polygonArea(poly)) > Math.max(eps, polygonArea(poly) * 1e-6))
|
||||
return null;
|
||||
return [p1, p2];
|
||||
}
|
||||
|
||||
@@ -441,7 +553,11 @@ export function resolveTapAction(
|
||||
cardDefault: string | null | undefined,
|
||||
domain: string | null | undefined,
|
||||
): TapAction {
|
||||
const want = explicit || cardDefault || 'info';
|
||||
// Pure light sources (the device's PRIMARY function is a lamp: bulbs,
|
||||
// chandeliers, night lights, light groups) toggle by default — no explicit
|
||||
// setting needed. Devices where light is a side function (a kettle's
|
||||
// backlight) have a non-light primary and keep the info default.
|
||||
const want = explicit || cardDefault || (domain === 'light' ? 'toggle' : 'info');
|
||||
if (want === 'more-info') return 'more-info';
|
||||
if (want !== 'toggle') return 'info';
|
||||
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
|
||||
@@ -484,7 +600,7 @@ export function subst(s: string, vars?: Record<string, string | number>): string
|
||||
|
||||
// ---------------- room fills & colors ----------------
|
||||
|
||||
export type RoomFillMode = 'none' | 'lqi' | 'light' | 'temp';
|
||||
export type RoomFillMode = 'none' | 'lqi' | 'light' | 'temp' | 'glow';
|
||||
|
||||
/** Per-space display settings with their defaults resolved. */
|
||||
export interface SpaceDisplay {
|
||||
@@ -497,6 +613,13 @@ export interface SpaceDisplay {
|
||||
tempMax: number; // comfort range upper bound, °C
|
||||
/** Per-space LQI badges near zigbee devices; null = follow the card option. */
|
||||
showLqi: boolean | null;
|
||||
/** Base font multiplier for room cards (tier 2; rooms multiply on top). */
|
||||
cardFontScale: number;
|
||||
/** Room-card metrics under the room name (all default off). */
|
||||
labelTemp: boolean;
|
||||
labelHum: boolean;
|
||||
labelLqi: boolean;
|
||||
labelLight: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
|
||||
@@ -513,10 +636,17 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
|
||||
showNames: s.show_names ?? noPlan,
|
||||
color: typeof s.room_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.room_color) ? s.room_color : DEFAULT_ROOM_COLOR,
|
||||
opacity: typeof s.room_opacity === 'number' ? Math.min(1, Math.max(0, s.room_opacity)) : DEFAULT_ROOM_OPACITY,
|
||||
fill: ['lqi', 'light', 'temp'].includes(s.fill_mode) ? s.fill_mode : 'none',
|
||||
fill: ['lqi', 'light', 'temp', 'glow'].includes(s.fill_mode) ? s.fill_mode : 'none',
|
||||
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
|
||||
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
|
||||
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
|
||||
cardFontScale: typeof s.card_font_scale === 'number' && s.card_font_scale > 0
|
||||
? Math.min(3, Math.max(0.5, s.card_font_scale))
|
||||
: 1,
|
||||
labelTemp: s.label_temp === true,
|
||||
labelHum: s.label_hum === true,
|
||||
labelLqi: s.label_lqi === true,
|
||||
labelLight: s.label_light === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -538,6 +668,9 @@ export interface FillColors {
|
||||
temp_hot: FillColorEntry;
|
||||
lqi_low: FillColorEntry;
|
||||
lqi_high: FillColorEntry;
|
||||
/** Glow mode: uniform "darkness" over every room + default light color. */
|
||||
glow_base: FillColorEntry;
|
||||
glow_light: FillColorEntry;
|
||||
}
|
||||
|
||||
export const DEFAULT_FILL_COLORS: FillColors = {
|
||||
@@ -549,6 +682,8 @@ export const DEFAULT_FILL_COLORS: FillColors = {
|
||||
temp_hot: { c: '#ffd45c', a: 0.18 },
|
||||
lqi_low: { c: '#f25a4a', a: 0.18 },
|
||||
lqi_high: { c: '#4bd28f', a: 0.18 },
|
||||
glow_base: { c: '#0d1b2a', a: 0.5 },
|
||||
glow_light: { c: '#ffd9a0', a: 0.85 },
|
||||
};
|
||||
|
||||
const HEX_RE = /^#[0-9a-f]{6}$/i;
|
||||
@@ -674,3 +809,467 @@ export function stateIcon(
|
||||
if (domain === 'light' && base === 'mdi:lightbulb') return state === 'on' ? 'mdi:lightbulb-on' : base;
|
||||
return base;
|
||||
}
|
||||
|
||||
// ---------------- light color & alarm states ----------------
|
||||
|
||||
/**
|
||||
* The current color of a light entity as a CSS color, or null when it is off,
|
||||
* unavailable or reports no usable color. rgb_color is the source of truth
|
||||
* (HA normalizes hs/xy into it); brightness is deliberately ignored — a dim
|
||||
* red bulb should still read as red on the plan.
|
||||
*/
|
||||
export function lightColorOf(state: any): string | null {
|
||||
if (!state || state.state !== 'on') return null;
|
||||
const rgb = state.attributes?.rgb_color;
|
||||
if (Array.isArray(rgb) && rgb.length >= 3 && rgb.every((v: any) => Number.isFinite(v))) {
|
||||
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------- glow fill (light sources) ----------------
|
||||
|
||||
/** Blackbody color temperature → RGB (Tanner Helland approximation). */
|
||||
export function kelvinToRgb(kelvin: number): [number, number, number] {
|
||||
const t = Math.min(40000, Math.max(1000, kelvin)) / 100;
|
||||
const r = t <= 66 ? 255 : 329.698727446 * Math.pow(t - 60, -0.1332047592);
|
||||
const g = t <= 66
|
||||
? 99.4708025861 * Math.log(t) - 161.1195681661
|
||||
: 288.1221695283 * Math.pow(t - 60, -0.0755148492);
|
||||
const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
|
||||
const cl = (v: number) => Math.round(Math.min(255, Math.max(0, v)));
|
||||
return [cl(r), cl(g), cl(b)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Color and relative brightness of a light's glow: rgb_color as is, else the
|
||||
* color temperature via blackbody, else the configured fallback. Off → null.
|
||||
*/
|
||||
export function glowColorOf(state: any, fallback: string): { c: string; bri: number } | null {
|
||||
if (!state || state.state !== 'on') return null;
|
||||
const a = state.attributes || {};
|
||||
const briRaw = Number(a.brightness);
|
||||
const bri = Number.isFinite(briRaw) && briRaw > 0 ? Math.max(0.15, Math.min(1, briRaw / 255)) : 1;
|
||||
const rgb = a.rgb_color;
|
||||
if (Array.isArray(rgb) && rgb.length >= 3 && rgb.every((v: any) => Number.isFinite(v)))
|
||||
return { c: `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`, bri };
|
||||
const kelvin = Number(a.color_temp_kelvin) || (Number(a.color_temp) > 0 ? 1e6 / Number(a.color_temp) : NaN);
|
||||
if (Number.isFinite(kelvin) && kelvin > 0) {
|
||||
const [r, g, b] = kelvinToRgb(kelvin);
|
||||
return { c: `rgb(${r}, ${g}, ${b})`, bri };
|
||||
}
|
||||
return { c: fallback, bri };
|
||||
}
|
||||
|
||||
/**
|
||||
* Light spilling through a doorway: the sector of the glow circle between the
|
||||
* rays source→A and source→B (door edge points), out to radius r. This part of
|
||||
* the circle is intentionally NOT clipped by the room (owner's spec: no shadow
|
||||
* casting — just the unclipped sector). Null when the door is out of reach or
|
||||
* the source sits on a door edge; the sweep is clamped to maxDeg.
|
||||
*/
|
||||
export function doorSector(
|
||||
src: number[], a: number[], b: number[], r: number, maxDeg = 170,
|
||||
): number[][] | null {
|
||||
const la = Math.hypot(a[0] - src[0], a[1] - src[1]);
|
||||
const lb = Math.hypot(b[0] - src[0], b[1] - src[1]);
|
||||
if (la < 1e-6 || lb < 1e-6 || Math.min(la, lb) >= r) return null;
|
||||
let aa = Math.atan2(a[1] - src[1], a[0] - src[0]);
|
||||
let sweep = Math.atan2(b[1] - src[1], b[0] - src[0]) - aa;
|
||||
while (sweep > Math.PI) sweep -= 2 * Math.PI;
|
||||
while (sweep < -Math.PI) sweep += 2 * Math.PI;
|
||||
const max = (maxDeg * Math.PI) / 180;
|
||||
if (Math.abs(sweep) > max) {
|
||||
const mid = aa + sweep / 2;
|
||||
sweep = max * Math.sign(sweep);
|
||||
aa = mid - sweep / 2;
|
||||
}
|
||||
const steps = 8;
|
||||
const pts: number[][] = [[src[0], src[1]]];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const ang = aa + (sweep * i) / steps;
|
||||
pts.push([src[0] + Math.cos(ang) * r, src[1] + Math.sin(ang) * r]);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is there a room on the far side of an opening (relative to the light source)?
|
||||
* Entrance doors lead outside — light must not spill there.
|
||||
*/
|
||||
export function hasRoomBehind(
|
||||
center: number[], angleDeg: number, src: number[], polys: number[][][], probe: number,
|
||||
): boolean {
|
||||
const rad = (angleDeg * Math.PI) / 180;
|
||||
const n = [-Math.sin(rad), Math.cos(rad)];
|
||||
const toSrc = (src[0] - center[0]) * n[0] + (src[1] - center[1]) * n[1];
|
||||
const sgn = toSrc > 0 ? -1 : 1;
|
||||
const p = [center[0] + n[0] * probe * sgn, center[1] + n[1] * probe * sgn];
|
||||
return polys.some((poly) => pointStrictlyInside(p, poly, 1e-9));
|
||||
}
|
||||
|
||||
/**
|
||||
* Group toggle for a switch's controlled entities, HA-group semantics:
|
||||
* any target on -> turn everything off; all off -> turn everything on.
|
||||
*/
|
||||
export function controlsAction(states: (string | undefined)[]): 'turn_on' | 'turn_off' {
|
||||
return states.some((st) => st === 'on') ? 'turn_off' : 'turn_on';
|
||||
}
|
||||
|
||||
/** Only lights and plain switches may be group-controlled from the plan. */
|
||||
export function isControllable(entityId: string): boolean {
|
||||
return entityId.startsWith('light.') || entityId.startsWith('switch.');
|
||||
}
|
||||
|
||||
// ---------------- open (virtual) boundaries ----------------
|
||||
|
||||
/**
|
||||
* Collinear overlapping stretches of two room outlines — their shared
|
||||
* boundary. Handles the real-house case where neighbouring walls only
|
||||
* PARTIALLY overlap (collinear, different lengths). Returns segments
|
||||
* [x1,y1,x2,y2] with length > eps.
|
||||
*/
|
||||
export function sharedBoundary(a: number[][], b: number[][], eps = 1e-6): number[][] {
|
||||
const res: number[][] = [];
|
||||
if (!a || !b || a.length < 3 || b.length < 3) return res;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const p1 = a[i], p2 = a[(i + 1) % a.length];
|
||||
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < eps) continue;
|
||||
const ux = dx / len, uy = dy / len;
|
||||
for (let j = 0; j < b.length; j++) {
|
||||
const q1 = b[j], q2 = b[(j + 1) % b.length];
|
||||
// both q endpoints must lie on the line of p1-p2
|
||||
const d1 = Math.abs((q1[0] - p1[0]) * uy - (q1[1] - p1[1]) * ux);
|
||||
const d2 = Math.abs((q2[0] - p1[0]) * uy - (q2[1] - p1[1]) * ux);
|
||||
const tol = Math.max(eps, len * 1e-6);
|
||||
if (d1 > tol || d2 > tol) continue;
|
||||
// overlap of parameter intervals along the line
|
||||
const t1 = (q1[0] - p1[0]) * ux + (q1[1] - p1[1]) * uy;
|
||||
const t2 = (q2[0] - p1[0]) * ux + (q2[1] - p1[1]) * uy;
|
||||
const lo = Math.max(0, Math.min(t1, t2));
|
||||
const hi = Math.min(len, Math.max(t1, t2));
|
||||
if (hi - lo > eps) {
|
||||
res.push([p1[0] + ux * lo, p1[1] + uy * lo, p1[0] + ux * hi, p1[1] + uy * hi]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connected component of rooms joined by open (virtual) boundaries — the
|
||||
* "open zone" light flows through. The open_to link counts in either
|
||||
* direction. Returns a set of room ids including the start.
|
||||
*/
|
||||
export function openZoneOf(roomId: string, rooms: { id?: string; open_to?: string[] | null }[]): Set<string> {
|
||||
const zone = new Set<string>([roomId]);
|
||||
const linked = (x: any, y: any) =>
|
||||
(x.open_to || []).includes(y.id) || (y.open_to || []).includes(x.id);
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const r of rooms) {
|
||||
if (!r.id || zone.has(r.id)) continue;
|
||||
for (const z of rooms) {
|
||||
if (!z.id || !zone.has(z.id)) continue;
|
||||
if (linked(r, z)) { zone.add(r.id); grew = true; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return zone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segments with the given collinear stretches removed — the workhorse behind
|
||||
* TRUE dashed open boundaries (derived walls and room outlines alike).
|
||||
*/
|
||||
export function cutSegments(segs: number[][], cuts: number[][], eps = 1e-6): number[][] {
|
||||
const out: number[][] = [];
|
||||
for (const seg of segs) {
|
||||
const p1 = [seg[0], seg[1]], p2 = [seg[2], seg[3]];
|
||||
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < eps) continue;
|
||||
const ux = dx / len, uy = dy / len;
|
||||
// collect cut intervals on this edge
|
||||
const iv: [number, number][] = [];
|
||||
for (const c of cuts) {
|
||||
const d1 = Math.abs((c[0] - p1[0]) * uy - (c[1] - p1[1]) * ux);
|
||||
const d2 = Math.abs((c[2] - p1[0]) * uy - (c[3] - p1[1]) * ux);
|
||||
const tol = Math.max(eps, len * 1e-6);
|
||||
if (d1 > tol || d2 > tol) continue;
|
||||
const t1 = (c[0] - p1[0]) * ux + (c[1] - p1[1]) * uy;
|
||||
const t2 = (c[2] - p1[0]) * ux + (c[3] - p1[1]) * uy;
|
||||
const lo = Math.max(0, Math.min(t1, t2));
|
||||
const hi = Math.min(len, Math.max(t1, t2));
|
||||
if (hi - lo > eps) iv.push([lo, hi]);
|
||||
}
|
||||
if (!iv.length) {
|
||||
out.push([p1[0], p1[1], p2[0], p2[1]]);
|
||||
continue;
|
||||
}
|
||||
iv.sort((a, b) => a[0] - b[0]);
|
||||
let cur = 0;
|
||||
for (const [lo, hi] of iv) {
|
||||
if (lo - cur > eps) out.push([p1[0] + ux * cur, p1[1] + uy * cur, p1[0] + ux * lo, p1[1] + uy * lo]);
|
||||
cur = Math.max(cur, hi);
|
||||
}
|
||||
if (len - cur > eps) out.push([p1[0] + ux * cur, p1[1] + uy * cur, p2[0], p2[1]]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Room outline pieces with the given collinear stretches removed. */
|
||||
export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6): number[][] {
|
||||
const edges: number[][] = [];
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const p1 = poly[i], p2 = poly[(i + 1) % poly.length];
|
||||
edges.push([p1[0], p1[1], p2[0], p2[1]]);
|
||||
}
|
||||
return cutSegments(edges, cuts, eps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy static URLs (/houseplan_files/plans|files/...) are rewritten to the
|
||||
* authenticated content endpoint (audit B1). Applied on READ, so stored
|
||||
* configs keep working without a migration.
|
||||
*/
|
||||
/**
|
||||
* How many paths one `houseplan/content/sign` call may carry. The backend caps
|
||||
* the request at the same number and silently ignores the rest, so a client
|
||||
* that sends more gets a partial answer with no way to tell which paths were
|
||||
* dropped — on a wall tablet those entries then expire for good (review R2-2).
|
||||
* Keep in sync with MAX_SIGN_PATHS in custom_components/houseplan/const.py.
|
||||
*/
|
||||
export const MAX_SIGN_PATHS = 200;
|
||||
|
||||
/** A signature is valid for 24 h; refresh once two thirds of it is gone. */
|
||||
export const SIGN_TTL_MS = 24 * 3600 * 1000;
|
||||
export const SIGN_REFRESH_MS = 16 * 3600 * 1000;
|
||||
|
||||
/** Split a list into chunks of at most `size` (used for signing batches). */
|
||||
export function chunk<T>(items: T[], size: number): T[][] {
|
||||
const n = Math.max(1, Math.floor(size));
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += n) out.push(items.slice(i, i + n));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every content url the given config still refers to, normalised through
|
||||
* `contentUrl`. The signature cache is pruned to this set: without it the cache
|
||||
* only grows — replaced plans and deleted attachments keep their entries, and
|
||||
* the total can cross the per-request cap even when the live config is small.
|
||||
*/
|
||||
export function referencedContentUrls(cfg: any): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const add = (u: unknown) => {
|
||||
if (typeof u !== 'string' || !u) return;
|
||||
const c = contentUrl(u);
|
||||
if (c.startsWith('/api/houseplan/content/')) out.add(c);
|
||||
};
|
||||
for (const sp of cfg?.spaces || []) {
|
||||
add(sp?.plan_url);
|
||||
for (const m of sp?.markers || []) for (const p of m?.pdfs || []) add(p?.url);
|
||||
}
|
||||
for (const m of cfg?.markers || []) for (const p of m?.pdfs || []) add(p?.url);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function contentUrl(url: string | null | undefined): string {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('/houseplan_files/plans/')) {
|
||||
return '/api/houseplan/content/plans/_/' + url.slice('/houseplan_files/plans/'.length);
|
||||
}
|
||||
if (url.startsWith('/houseplan_files/files/')) {
|
||||
return '/api/houseplan/content/files/' + url.slice('/houseplan_files/files/'.length);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// ---------------- room-level settings (tier 3) ----------------
|
||||
|
||||
/**
|
||||
* Effective fill mode of a room: its own override wins, otherwise the space's.
|
||||
* Four settings tiers (owner's principle, 2026-07-26): global > space > room >
|
||||
* device; the more specific tier overrides the more general one. A room may
|
||||
* override even in a glow space ('none' pulls it out of the darkness).
|
||||
*/
|
||||
export function roomFillModeOf(
|
||||
spaceFill: RoomFillMode,
|
||||
room: { settings?: { fill_mode?: string | null } | null } | null | undefined,
|
||||
): RoomFillMode {
|
||||
const o = room?.settings?.fill_mode;
|
||||
return o === 'none' || o === 'lqi' || o === 'light' || o === 'temp' ? o : spaceFill;
|
||||
}
|
||||
|
||||
// ---------------- marker files ----------------
|
||||
|
||||
/**
|
||||
* Rewrite attached-file urls when a marker's id changes (rebinding): the
|
||||
* server moves /files/<oldId>/ to /files/<newId>/, the urls must follow.
|
||||
*/
|
||||
export function migratePdfUrls<T extends { url: string }>(
|
||||
pdfs: T[], oldId: string, newId: string, mapping?: Record<string, string>,
|
||||
): T[] {
|
||||
if (!oldId || !newId || oldId === newId) return pdfs;
|
||||
const from = '/files/' + oldId + '/';
|
||||
const to = '/files/' + newId + '/';
|
||||
return pdfs.map((p) => {
|
||||
if (!p.url.includes(from)) return p;
|
||||
const tail = p.url.split(from)[1] || '';
|
||||
const [name, query] = [tail.split('?')[0], tail.includes('?') ? '?' + tail.split('?')[1] : ''];
|
||||
if (mapping) {
|
||||
// review CR-3: rewrite ONLY files the server confirmed it copied, and use
|
||||
// the name it actually wrote (collisions get a unique name). A url that
|
||||
// was not copied keeps pointing at the still-existing old folder.
|
||||
const dst = mapping[decodeURIComponent(name)] ?? mapping[name];
|
||||
if (!dst) return p;
|
||||
return { ...p, url: p.url.split(from + name)[0] + to + encodeURIComponent(dst) + query };
|
||||
}
|
||||
return { ...p, url: p.url.split(from).join(to) };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- kiosk gestures ----------------
|
||||
|
||||
/**
|
||||
* Kiosk swipe: which neighbouring space a horizontal gesture selects.
|
||||
* Only fires at 1:1 zoom (owner's decision — when zoomed the gesture pans),
|
||||
* needs a mostly-horizontal move of at least minPx. Wraps around.
|
||||
*/
|
||||
export function swipeTarget(
|
||||
dx: number, dy: number, zoom: number, spaceIds: string[], current: string, minPx = 60,
|
||||
): string | null {
|
||||
if (zoom > 1.001 || spaceIds.length < 2) return null;
|
||||
if (Math.abs(dx) < minPx || Math.abs(dx) < Math.abs(dy) * 1.5) return null;
|
||||
const i = spaceIds.indexOf(current);
|
||||
if (i < 0) return null;
|
||||
const n = spaceIds.length;
|
||||
return dx < 0 ? spaceIds[(i + 1) % n] : spaceIds[(i - 1 + n) % n];
|
||||
}
|
||||
|
||||
/** Clamp a per-screen size multiplier (icons / room-card font). */
|
||||
export function clampScale(v: unknown, def = 1): number {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n > 0 ? Math.min(3, Math.max(0.5, n)) : def;
|
||||
}
|
||||
|
||||
// ---------------- alignment guides ----------------
|
||||
|
||||
export interface AlignGuide {
|
||||
axis: 'x' | 'y';
|
||||
/** The candidate's aligned coordinate (x for axis x, y for axis y). */
|
||||
at: number;
|
||||
/** The candidate point the guide is drawn from. */
|
||||
from: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Alignment guides for a point being drawn/dragged: the nearest candidate
|
||||
* sharing its X and the nearest sharing its Y (within tol). Indication only —
|
||||
* no magnetism, the grid owns the actual position (owner's decision).
|
||||
*/
|
||||
export function alignGuides(pt: number[], candidates: number[][], tol: number): AlignGuide[] {
|
||||
let bestX: { d: number; c: number[] } | null = null;
|
||||
let bestY: { d: number; c: number[] } | null = null;
|
||||
for (const c of candidates) {
|
||||
const same = Math.abs(c[0] - pt[0]) < 1e-6 && Math.abs(c[1] - pt[1]) < 1e-6;
|
||||
if (same) continue;
|
||||
if (Math.abs(c[0] - pt[0]) <= tol) {
|
||||
const d = Math.abs(c[1] - pt[1]);
|
||||
if (d > 1e-6 && (!bestX || d < bestX.d)) bestX = { d, c };
|
||||
}
|
||||
if (Math.abs(c[1] - pt[1]) <= tol) {
|
||||
const d = Math.abs(c[0] - pt[0]);
|
||||
if (d > 1e-6 && (!bestY || d < bestY.d)) bestY = { d, c };
|
||||
}
|
||||
}
|
||||
const out: AlignGuide[] = [];
|
||||
if (bestX) out.push({ axis: 'x', at: bestX.c[0], from: bestX.c });
|
||||
if (bestY) out.push({ axis: 'y', at: bestY.c[1], from: bestY.c });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Segment angle in degrees, normalized to [0, 360). */
|
||||
export function segmentAngle(a: number[], b: number[]): number {
|
||||
let deg = (Math.atan2(b[1] - a[1], b[0] - a[0]) * 180) / Math.PI;
|
||||
if (deg < 0) deg += 360;
|
||||
return deg;
|
||||
}
|
||||
|
||||
/** Is the angle a multiple of 45° (within tolerance)? */
|
||||
export function is45(deg: number, tol = 0.5): boolean {
|
||||
const m = ((deg % 45) + 45) % 45;
|
||||
return m <= tol || 45 - m <= tol;
|
||||
}
|
||||
|
||||
/** Distance from a point to a segment [x1,y1,x2,y2]. */
|
||||
export function distToSegment(p: number[], s: number[]): number {
|
||||
const dx = s[2] - s[0], dy = s[3] - s[1];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (!len2) return Math.hypot(p[0] - s[0], p[1] - s[1]);
|
||||
let t = ((p[0] - s[0]) * dx + (p[1] - s[1]) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return Math.hypot(p[0] - (s[0] + t * dx), p[1] - (s[1] + t * dy));
|
||||
}
|
||||
|
||||
/** Device classes whose active state is an emergency, not a status. */
|
||||
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
|
||||
|
||||
/**
|
||||
* An alarm is firing: leak/smoke/gas/CO/safety binary sensors in `on`, or a
|
||||
* siren that is on. Unavailable/unknown never alarm (an outage is not a fire).
|
||||
*/
|
||||
export function isAlarmState(
|
||||
domain: string | null | undefined,
|
||||
deviceClass: string | null | undefined,
|
||||
state: string | null | undefined,
|
||||
): boolean {
|
||||
if (state !== 'on') return false;
|
||||
if (domain === 'siren') return true;
|
||||
return domain === 'binary_sensor' && !!deviceClass && ALARM_CLASSES.has(deviceClass);
|
||||
}
|
||||
|
||||
// ---------------- room references ----------------
|
||||
|
||||
/**
|
||||
* Parse a marker's room reference. Two shapes:
|
||||
* - `space#area` — a room bound to an HA area (historical form)
|
||||
* - `space#@roomId` — a room WITHOUT an area (sub-area rooms, issue #3):
|
||||
* devices are placed into it manually, by room id.
|
||||
*/
|
||||
export function parseRoomRef(
|
||||
v: string | null | undefined,
|
||||
): { space: string; area: string | null; roomId: string | null } | null {
|
||||
if (!v) return null;
|
||||
const i = v.indexOf('#');
|
||||
if (i <= 0) return null;
|
||||
const space = v.slice(0, i);
|
||||
const rest = v.slice(i + 1);
|
||||
if (!rest) return null;
|
||||
if (rest.startsWith('@')) {
|
||||
const roomId = rest.slice(1);
|
||||
return roomId ? { space, area: null, roomId } : null;
|
||||
}
|
||||
return { space, area: rest, roomId: null };
|
||||
}
|
||||
|
||||
// ---------------- new-device detection ----------------
|
||||
|
||||
/**
|
||||
* Which auto-appearing device ids are NEW against the known baseline.
|
||||
* No baseline yet (first run / upgrade) → nothing is new: every current id
|
||||
* becomes the baseline silently, so an update never floods the plan with dots.
|
||||
*/
|
||||
export function diffNewDevices(
|
||||
currentIds: string[],
|
||||
known: string[] | null | undefined,
|
||||
): { fresh: string[]; known: string[] } {
|
||||
if (!Array.isArray(known)) return { fresh: [], known: [...currentIds] };
|
||||
const knownSet = new Set(known);
|
||||
const fresh = currentIds.filter((id) => !knownSet.has(id));
|
||||
return { fresh, known: fresh.length ? [...known, ...fresh] : known };
|
||||
}
|
||||
|
||||
+4
-1
@@ -29,6 +29,8 @@ export const DEFAULT_ICON_RULES: IconRule[] = [
|
||||
{ pattern: 'клапан|valve', icon: 'mdi:pipe-valve' },
|
||||
{ pattern: 'дым|smoke', icon: 'mdi:smoke-detector' },
|
||||
{ pattern: 'термоголов|trv|radiator', icon: 'mdi:radiator' },
|
||||
{ pattern: 'чайник|kettle|термопот', icon: 'mdi:kettle' },
|
||||
{ pattern: 'сауна|sauna|harvia|парная|парилк', icon: 'mdi:hot-tub' },
|
||||
{ pattern: 'температ|temperature|climate sensor', icon: 'mdi:thermometer' },
|
||||
{ pattern: 'qingping|air monitor|молекул|air quality', icon: 'mdi:air-filter' },
|
||||
{ pattern: 'штор|curtain|blind|shade', icon: 'mdi:roller-shade' },
|
||||
@@ -46,7 +48,8 @@ export const DEFAULT_ICON_RULES: IconRule[] = [
|
||||
{ pattern: 'стиральн|washer|washing', icon: 'mdi:washing-machine' },
|
||||
{ pattern: 'сушилк|dryer', icon: 'mdi:tumble-dryer' },
|
||||
{ pattern: 'пылесос|vacuum|dreame|roborock', icon: 'mdi:robot-vacuum' },
|
||||
{ pattern: 'soundbar|колонк|станц|speaker', icon: 'mdi:soundbar' },
|
||||
{ pattern: 'soundbar', icon: 'mdi:soundbar' },
|
||||
{ pattern: 'колонк|станц|speaker|яндекс|yandex|алиса|alice', icon: 'mdi:speaker' },
|
||||
{ pattern: 'tv|телевизор|hyundaitv|mitv|television', icon: 'mdi:television' },
|
||||
{ pattern: 'keenetic|роутер|router|mesh|access point', icon: 'mdi:router-wireless' },
|
||||
{ pattern: 'ибп|ups|kirpich', icon: 'mdi:battery-charging-high' },
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from './logic';
|
||||
|
||||
/** A request older than this is presumed lost, and the url may be asked again. */
|
||||
export const SIGN_INFLIGHT_MS = 15000;
|
||||
/** After a failure, wait before retrying; doubles up to the cap. */
|
||||
export const SIGN_BACKOFF_MIN_MS = 2000;
|
||||
export const SIGN_BACKOFF_MAX_MS = 60000;
|
||||
|
||||
/**
|
||||
* Signed urls for the authenticated content endpoint, shared by both cards.
|
||||
*
|
||||
* A browser cannot authenticate an `<image href>` or an `<a href>`: Home
|
||||
* Assistant takes a Bearer header or an `authSig` signed path, and an element
|
||||
* sends neither. So whatever is about to be displayed has to be signed first.
|
||||
*
|
||||
* This used to be implemented twice — and the second copy (houseplan-space-card)
|
||||
* signed correctly but never handed the result to its renderer, so the plan
|
||||
* background asked for the raw protected url and got a 401 on every render
|
||||
* (review R3-2). One implementation, one set of rules:
|
||||
*
|
||||
* - requests are chunked to MAX_SIGN_PATHS, the cap the backend silently
|
||||
* applies (an oversized call comes back partial with no way to tell what was
|
||||
* dropped, and those entries then expire for good);
|
||||
* - every entry carries the time it was issued: past SIGN_REFRESH_MS a
|
||||
* replacement is fetched while the old url keeps rendering, past SIGN_TTL_MS
|
||||
* the entry is dropped rather than served (it would 401 and raise a
|
||||
* failed-login warning for the viewer's own IP);
|
||||
* - a url that is queued or already in flight is not asked for again: renders
|
||||
* are frequent and a slow socket used to turn every one of them into another
|
||||
* `content/sign` call (review R4-2). An in-flight entry expires after
|
||||
* SIGN_INFLIGHT_MS so a promise that never settles cannot block retries
|
||||
* forever, and a failure backs off instead of retrying on the next frame.
|
||||
*/
|
||||
export class ContentSigner {
|
||||
private signed: Record<string, { url: string; at: number }> = {};
|
||||
/** Waiting for the batch timer to fire. */
|
||||
private queued = new Set<string>();
|
||||
/** Sent and not settled yet: url -> when the request went out. */
|
||||
private inFlight = new Map<string, number>();
|
||||
/** After a failure: when this url may be asked again, and the current delay. */
|
||||
private retry = new Map<string, { notBefore: number; delay: number }>();
|
||||
private batchTimer?: ReturnType<typeof setTimeout>;
|
||||
private resignTimer?: ReturnType<typeof setInterval>;
|
||||
private disposed = false;
|
||||
|
||||
/**
|
||||
* @param onUpdate schedule a re-render (a signature arriving changes the DOM)
|
||||
* @param now injectable clock — the tests need to age a signature
|
||||
*/
|
||||
constructor(private onUpdate: () => void, private now: () => number = () => Date.now()) {}
|
||||
|
||||
/** Start the periodic re-sign. `referenced` prunes the cache on each tick. */
|
||||
start(hass: () => any, referenced: () => Set<string>): void {
|
||||
this.disposed = false; // an element can be reconnected after a disconnect
|
||||
this.stopTimer();
|
||||
this.resignTimer = setInterval(() => this.resign(hass(), referenced()), SIGN_REFRESH_MS / 2);
|
||||
}
|
||||
|
||||
/** Release every timer; the cache survives a reconnect, the timers must not. */
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.stopTimer();
|
||||
clearTimeout(this.batchTimer);
|
||||
this.queued.clear();
|
||||
this.inFlight.clear();
|
||||
}
|
||||
|
||||
private stopTimer(): void {
|
||||
if (this.resignTimer !== undefined) clearInterval(this.resignTimer);
|
||||
this.resignTimer = undefined;
|
||||
}
|
||||
|
||||
/** The url to put in the DOM: a signature we hold and still trust, else ''. */
|
||||
display(hass: any, url: string | null | undefined): string {
|
||||
const u = contentUrl(url);
|
||||
if (!u.startsWith('/api/houseplan/content/')) return u;
|
||||
const hit = this.signed[u];
|
||||
const age = hit ? this.now() - hit.at : Infinity;
|
||||
if (age < SIGN_REFRESH_MS) return hit.url;
|
||||
if (age < SIGN_TTL_MS) {
|
||||
// aging but still valid: keep showing it while a fresh one is fetched
|
||||
this.request(hass, u);
|
||||
return hit.url;
|
||||
}
|
||||
if (hit) delete this.signed[u];
|
||||
this.request(hass, u);
|
||||
// Empty, NOT the plain path: an unsigned request to a `requires_auth` view
|
||||
// returns 401 and Home Assistant raises a "failed login attempt".
|
||||
return '';
|
||||
}
|
||||
|
||||
private request(hass: any, url: string): void {
|
||||
if (!hass?.callWS || this.queued.has(url)) return;
|
||||
const now = this.now();
|
||||
const sent = this.inFlight.get(url);
|
||||
if (sent !== undefined && now - sent < SIGN_INFLIGHT_MS) return; // already asking
|
||||
const back = this.retry.get(url);
|
||||
if (back && now < back.notBefore) return; // still backing off
|
||||
this.queued.add(url);
|
||||
clearTimeout(this.batchTimer);
|
||||
// batch: switching space asks for several urls in the same tick
|
||||
this.batchTimer = setTimeout(() => {
|
||||
const paths = [...this.queued];
|
||||
this.queued.clear();
|
||||
this.sign(hass, paths);
|
||||
}, 30);
|
||||
}
|
||||
|
||||
private sign(hass: any, paths: string[]): void {
|
||||
if (!paths.length || !hass?.callWS) return;
|
||||
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
|
||||
const sentAt = this.now();
|
||||
for (const p of batch) this.inFlight.set(p, sentAt);
|
||||
hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: batch })
|
||||
.then((r: any) => {
|
||||
for (const p of batch) this.retry.delete(p);
|
||||
if (!r?.urls || this.disposed) return;
|
||||
const at = this.now();
|
||||
const next = { ...this.signed };
|
||||
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at };
|
||||
this.signed = next;
|
||||
this.onUpdate();
|
||||
})
|
||||
.catch(() => {
|
||||
// back off rather than retry on the very next frame: a socket that
|
||||
// is refusing sign requests would otherwise be hammered per render
|
||||
const now = this.now();
|
||||
for (const p of batch) {
|
||||
const prev = this.retry.get(p)?.delay || 0;
|
||||
const delay = Math.min(SIGN_BACKOFF_MAX_MS, prev ? prev * 2 : SIGN_BACKOFF_MIN_MS);
|
||||
this.retry.set(p, { notBefore: now + delay, delay });
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
// release only our own attempt: a later one may have superseded it
|
||||
for (const p of batch) if (this.inFlight.get(p) === sentAt) this.inFlight.delete(p);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sign what is still in use. A wall tablet outlives a signature, and an
|
||||
* entry for a plan replaced months ago must not consume a slot in the capped
|
||||
* request — so prune to the urls the live config still references.
|
||||
*/
|
||||
resign(hass: any, referenced: Set<string>): void {
|
||||
const now = this.now();
|
||||
const kept: Record<string, { url: string; at: number }> = {};
|
||||
for (const [k, v] of Object.entries(this.signed)) {
|
||||
if (referenced.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
|
||||
}
|
||||
this.signed = kept;
|
||||
this.retry.clear(); // a scheduled refresh is a fresh chance for everything
|
||||
this.sign(hass, Object.keys(kept));
|
||||
}
|
||||
|
||||
/** Test/debug view of the cache. */
|
||||
get entries(): Record<string, { url: string; at: number }> {
|
||||
return this.signed;
|
||||
}
|
||||
|
||||
/** Test/debug view of what is currently being asked for. */
|
||||
get inFlightUrls(): string[] {
|
||||
return [...this.inFlight.keys()];
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import { cardStyles } from './styles';
|
||||
import { renderSpaceStatic, spaceModels } from './space-render';
|
||||
import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store';
|
||||
import { t, langOf, type Lang } from './i18n';
|
||||
import { ContentSigner } from './signing';
|
||||
import { referencedContentUrls } from './logic';
|
||||
import './space-editor';
|
||||
|
||||
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
|
||||
@@ -73,11 +75,14 @@ class HouseplanSpaceCard extends LitElement {
|
||||
this._snap = null;
|
||||
this.requestUpdate();
|
||||
});
|
||||
// a dashboard on a wall tablet outlives a 24 h signature
|
||||
this._signer.start(() => this.hass, () => this._referenced());
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this._unsub?.();
|
||||
this._unsub = undefined;
|
||||
this._signer.dispose();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -121,6 +126,18 @@ class HouseplanSpaceCard extends LitElement {
|
||||
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same signer as the main card (review R3-2). The previous copy here signed
|
||||
* the url and then threw it away: `getCardSize()` mutated a throwaway model
|
||||
* while `render()` rebuilt its own from the config, so the <image> kept
|
||||
* asking for the raw protected path and got a 401 on every render.
|
||||
*/
|
||||
private _signer = new ContentSigner(() => this.requestUpdate());
|
||||
|
||||
private _referenced(): Set<string> {
|
||||
return referencedContentUrls(this._snap?.config);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this._config) return nothing;
|
||||
const cfg = this._snap?.config;
|
||||
@@ -136,6 +153,8 @@ class HouseplanSpaceCard extends LitElement {
|
||||
spaceId,
|
||||
iconSize: this._config.icon_size,
|
||||
lang: this._lang,
|
||||
// resolved at render time: a url baked in earlier would be the unsigned one
|
||||
displayUrl: (raw) => this._signer.display(this.hass, raw),
|
||||
});
|
||||
if (!stage) {
|
||||
return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId }));
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* are directly unit-tested. Shared by the static renderer (space-render.ts) and
|
||||
* mirror the full card's private math.
|
||||
*/
|
||||
import { declump } from './logic';
|
||||
import { declump, contentUrl } from './logic';
|
||||
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
|
||||
|
||||
export const NORM_W = 1000; // width of the render space for normalized configs
|
||||
@@ -30,7 +30,7 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
rooms: (s.rooms || []).map(scale),
|
||||
} as SpaceModel;
|
||||
});
|
||||
|
||||
+11
-2
@@ -25,6 +25,13 @@ export interface StaticRenderOpts {
|
||||
spaceId: string;
|
||||
iconSize?: number;
|
||||
lang: Lang;
|
||||
/**
|
||||
* Resolve a stored content url to what the DOM may actually request — the
|
||||
* plan lives behind `requires_auth`, so it needs an `authSig` signature.
|
||||
* Returning '' means "not signed yet": the caller must render no <image>
|
||||
* rather than an unsigned one, which would 401 (review R3-2).
|
||||
*/
|
||||
displayUrl?: (raw: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,11 +125,13 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
})
|
||||
: [];
|
||||
|
||||
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
|
||||
|
||||
return html`
|
||||
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}">
|
||||
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
|
||||
${space.bg
|
||||
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
||||
${bgHref
|
||||
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
|
||||
: nothing}
|
||||
${roomShapes}
|
||||
</svg>
|
||||
|
||||
+438
-20
@@ -80,6 +80,8 @@ export const cardStyles = css`
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--hp-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 13px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
@@ -187,7 +189,7 @@ export const cardStyles = css`
|
||||
}
|
||||
.room {
|
||||
transition: 0.12s;
|
||||
cursor: pointer;
|
||||
cursor: default; /* v1.40.1: rooms are not clickable — the label's link icon is */
|
||||
}
|
||||
.room.overlay {
|
||||
fill: transparent;
|
||||
@@ -242,22 +244,26 @@ export const cardStyles = css`
|
||||
transition: opacity 0.15s;
|
||||
pointer-events: none;
|
||||
}
|
||||
g.opening:hover .op-outline {
|
||||
.stage.markup g.opening:hover .op-outline {
|
||||
opacity: 0.9;
|
||||
}
|
||||
/* openings are pure status graphics outside Plan mode: no cursor, no hover,
|
||||
no hit target — View must not interact with them at all */
|
||||
.op-hit {
|
||||
fill: transparent;
|
||||
cursor: grab;
|
||||
pointer-events: auto;
|
||||
touch-action: none; /* drags, not scrolls, on touch */
|
||||
}
|
||||
.op-hit:active {
|
||||
cursor: grabbing;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
.stage.markup .op-hit {
|
||||
pointer-events: none; /* markup clicks go to the stage tools */
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
touch-action: none; /* drags, not scrolls, on touch */
|
||||
}
|
||||
.stage.markup .op-hit:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.oplock {
|
||||
pointer-events: none; /* inert while editing; clickable in View (rule below) */
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: calc(var(--icon-size, 2.5cqw) * 0.62);
|
||||
@@ -268,9 +274,11 @@ export const cardStyles = css`
|
||||
justify-content: center;
|
||||
background: var(--hp-bg);
|
||||
border: 1px solid var(--hp-line);
|
||||
z-index: 1;
|
||||
}
|
||||
.stage.mode-view .oplock {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
.oplock ha-icon {
|
||||
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.4);
|
||||
@@ -280,6 +288,18 @@ export const cardStyles = css`
|
||||
.oplock.locked { color: #66d17a; border-color: #66d17a; }
|
||||
.oplock.unlocked { color: var(--hp-open); border-color: var(--hp-open); }
|
||||
.oplock.unknown { color: var(--hp-muted); }
|
||||
.btn.lockact {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn.lockact.warn {
|
||||
color: var(--error-color, #d33);
|
||||
border-color: var(--error-color, #d33);
|
||||
}
|
||||
.oprow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -344,14 +364,179 @@ export const cardStyles = css`
|
||||
pointer-events: none; /* draggable only in plan mode (rule below) */
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.5);
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.5 * var(--rl-scale, 1) * var(--rl-font, 1) * var(--rl-space, 1));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15em;
|
||||
text-align: center;
|
||||
}
|
||||
.rlname {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
font-size: calc(1em * var(--rl-name, 1));
|
||||
}
|
||||
.rlgearbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 3px;
|
||||
padding: 3px 8px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
opacity: 0.92;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.rlgearbtn:hover { opacity: 1; }
|
||||
.rlgearbtn ha-icon { --mdc-icon-size: 14px; display: inline-flex; }
|
||||
.rlgear {
|
||||
--mdc-icon-size: 0.9em;
|
||||
display: inline-flex;
|
||||
margin-right: 0.2em;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.rlgear:hover { opacity: 1; }
|
||||
.rlgo {
|
||||
--mdc-icon-size: 0.85em;
|
||||
display: inline-flex;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.stage.mode-view .rlgo {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stage.mode-view .rlgo:hover { opacity: 1; }
|
||||
.roomlabel .rlmetrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55em;
|
||||
font-size: calc(0.75em * var(--rl-meta, 1)); /* feedback: 0.62 was unreadable on a tablet */
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.roomlabel .rlm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.12em;
|
||||
}
|
||||
.roomlabel .rlm ha-icon {
|
||||
--mdc-icon-size: 1.05em;
|
||||
display: inline-flex;
|
||||
}
|
||||
.roomlabel .rlm.lit { opacity: 1; }
|
||||
.bindharow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.bindharow .entcheck { opacity: 0.9; }
|
||||
.dropbtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid var(--hp-muted);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--hp-txt);
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.dropbtn .ref { color: var(--hp-muted); font-size: 11px; margin-left: auto; }
|
||||
.dropbtn ha-icon { --mdc-icon-size: 18px; margin-left: 4px; }
|
||||
.dropbtn.open { border-color: var(--hp-accent); }
|
||||
.droppanel {
|
||||
border: 1px solid var(--hp-accent);
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
padding: 6px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
.ctrlchips { display: flex; flex-wrap: wrap; gap: 5px; margin: 4px 0; }
|
||||
.ctrlchip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
background: var(--hp-accent); color: var(--text-primary-color, #fff);
|
||||
border-radius: 12px; padding: 3px 8px; font-size: 12px;
|
||||
}
|
||||
.ctrlchip ha-icon { --mdc-icon-size: 14px; cursor: pointer; }
|
||||
.ctrllist { display: flex; flex-direction: column; gap: 2px; margin-top: 4px; }
|
||||
.ctrlopt {
|
||||
display: flex; align-items: center; gap: 7px; text-align: left;
|
||||
border: 0; background: transparent; color: var(--hp-txt);
|
||||
padding: 5px 7px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 13px;
|
||||
}
|
||||
.ctrlopt:hover { background: var(--secondary-background-color, rgba(128,128,128,0.15)); }
|
||||
.ctrlopt .sub { color: var(--hp-muted); font-size: 11px; margin-left: auto; }
|
||||
.ctrlopt ha-icon { --mdc-icon-size: 16px; }
|
||||
.ctrlstates { display: flex; flex-direction: column; gap: 3px; }
|
||||
.ctrlstate { display: inline-flex; align-items: center; gap: 5px; color: var(--hp-muted); }
|
||||
.ctrlstate.on { color: var(--hp-txt); }
|
||||
.ctrlstate ha-icon { --mdc-icon-size: 15px; }
|
||||
.cardpreview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 8px 0 2px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--hp-muted);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.cardpreview .cpname { font-weight: 700; letter-spacing: 0.04em; }
|
||||
.cardpreview .cpmeta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3em;
|
||||
font-weight: 600;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cardpreview .cpmeta ha-icon { --mdc-icon-size: 1.05em; }
|
||||
.iconauto {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.iconauto ha-icon { --mdc-icon-size: 18px; }
|
||||
.rlhandle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
background: var(--hp-accent);
|
||||
border: 1px solid var(--card-background-color, #fff);
|
||||
z-index: 2;
|
||||
}
|
||||
.rlhandle.tl { left: -6px; top: -6px; cursor: nwse-resize; }
|
||||
.rlhandle.br { right: -6px; bottom: -6px; cursor: nwse-resize; }
|
||||
.rlhandle.tr { right: -6px; top: -6px; cursor: nesw-resize; }
|
||||
.rlhandle.bl { left: -6px; bottom: -6px; cursor: nesw-resize; }
|
||||
.stage.markup .roomlabel:hover .rlhandle { display: block; }
|
||||
.stage.markup .roomlabel { pointer-events: auto; }
|
||||
.roomlabel:active { cursor: grabbing; }
|
||||
.measurelayer {
|
||||
@@ -359,6 +544,40 @@ export const cardStyles = css`
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.alignline {
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: 1.2;
|
||||
stroke-dasharray: 4 4;
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.aligndot {
|
||||
fill: var(--hp-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.measurelabel.on45 {
|
||||
color: #4bd28f;
|
||||
border-color: #4bd28f;
|
||||
}
|
||||
.hdr.kioskhide { display: none; }
|
||||
.kioskdots {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 14px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.kdot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--hp-muted);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.kdot.on { background: var(--hp-accent); opacity: 1; }
|
||||
.measurelabel {
|
||||
position: absolute;
|
||||
transform: translate(12px, -150%);
|
||||
@@ -373,6 +592,68 @@ export const cardStyles = css`
|
||||
user-select: none;
|
||||
z-index: 3;
|
||||
}
|
||||
/* decor (background) layer */
|
||||
.decorlayer .dshape { pointer-events: none; }
|
||||
.stage.mode-decor .decorlayer .dshape {
|
||||
pointer-events: visiblePainted;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stage.mode-decor.dtool-select .decorlayer .dshape { cursor: move; }
|
||||
.decorlayer .dsel {
|
||||
filter: drop-shadow(0 0 3px var(--hp-accent));
|
||||
}
|
||||
.decorlayer .ddraft {
|
||||
opacity: 0.75;
|
||||
stroke-dasharray: 6 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.decorlayer text {
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
dominant-baseline: middle;
|
||||
text-anchor: middle;
|
||||
}
|
||||
.stage.mode-decor {
|
||||
outline: 2px solid #26a69a;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.stage.mode-decor.dtool-line, .stage.mode-decor.dtool-rect,
|
||||
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.stage.mode-decor .room, .stage.mode-decor .devlayer { pointer-events: none; }
|
||||
.stage.mode-decor .oplock { pointer-events: none; }
|
||||
/* decor mode: everything but the decor itself fades back */
|
||||
.stage.mode-decor .room,
|
||||
.stage.mode-decor .devlayer,
|
||||
.stage.mode-decor .opening,
|
||||
.stage.mode-decor .rlabel {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.decorbar .dcolor {
|
||||
width: 30px; height: 26px; padding: 0; border: none; background: none; cursor: pointer;
|
||||
}
|
||||
.decorbar .dwidth {
|
||||
font-family: inherit; font-size: 12px; border-radius: 6px;
|
||||
background: var(--hp-bg2, transparent); color: var(--hp-txt); border: 1px solid var(--hp-muted);
|
||||
padding: 3px 5px;
|
||||
}
|
||||
.decorbar .dfill {
|
||||
display: inline-flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.opghost {
|
||||
stroke: var(--hp-open, #ff9800);
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 7 6;
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
.opghost-dot {
|
||||
fill: var(--hp-open, #ff9800);
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
.rlabel {
|
||||
fill: var(--hp-muted);
|
||||
font-size: 15px;
|
||||
@@ -386,6 +667,55 @@ export const cardStyles = css`
|
||||
.stage.markup {
|
||||
cursor: crosshair;
|
||||
}
|
||||
/* room-picking stages: merge (both clicks) and split before a room is chosen */
|
||||
.stage.markup.tool-merge,
|
||||
.stage.markup.tool-split.pickstage,
|
||||
.stage.markup.tool-delroom {
|
||||
cursor: pointer;
|
||||
}
|
||||
/* open-wall tool: default until a shared wall is under the cursor */
|
||||
.stage.markup.tool-openwall { cursor: default; }
|
||||
.stage.markup.tool-openwall.wallhot { cursor: pointer; }
|
||||
.openwall {
|
||||
stroke: var(--ow-stroke, var(--hp-muted));
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 7 7;
|
||||
stroke-linecap: butt;
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
/* rooms with open stretches: the polygon's own stroke is fully off
|
||||
(hover included) — the trimmed .room-outline path draws the walls */
|
||||
.room.noedge {
|
||||
stroke-opacity: 0 !important;
|
||||
}
|
||||
.room-outline {
|
||||
fill: none;
|
||||
stroke-width: 2.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Plan editor: trimmed outlines use the markup blue */
|
||||
.room-outline.outlined {
|
||||
stroke: rgba(62, 166, 255, 0.55);
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
.openwalls.hot .openwall {
|
||||
stroke: #ffc14d;
|
||||
opacity: 1;
|
||||
}
|
||||
.openwall-preview {
|
||||
stroke: #ffc14d;
|
||||
stroke-width: 5;
|
||||
stroke-dasharray: 7 7;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
opacity: 0.95;
|
||||
}
|
||||
/* an already-open boundary under the cursor: the click will CLOSE it */
|
||||
.openwall-preview.willclose {
|
||||
stroke: #f25a4a;
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
.stage.markup .room {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -423,6 +753,20 @@ export const cardStyles = css`
|
||||
font-family: inherit;
|
||||
}
|
||||
.modetab ha-icon { --mdc-icon-size: 15px; }
|
||||
.modetab .closex {
|
||||
--mdc-icon-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 2px;
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.modetab .closex:hover { opacity: 1; }
|
||||
.editbar .barclose {
|
||||
padding: 4px 6px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.modetab.active {
|
||||
background: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
@@ -430,15 +774,16 @@ export const cardStyles = css`
|
||||
@media (max-width: 720px) {
|
||||
.modetab .ml { display: none; }
|
||||
}
|
||||
.room.outlined {
|
||||
stroke: rgba(62, 166, 255, 0.55);
|
||||
fill: rgba(62, 166, 255, 0.06);
|
||||
}
|
||||
/* AFTER .outlined: same specificity — source order decides (gotcha x4) */
|
||||
.room.picked {
|
||||
stroke: #ffc14d;
|
||||
stroke-width: 3;
|
||||
fill: rgba(255, 193, 77, 0.25);
|
||||
}
|
||||
.room.outlined {
|
||||
stroke: rgba(62, 166, 255, 0.55);
|
||||
fill: rgba(62, 166, 255, 0.06);
|
||||
}
|
||||
.griddot {
|
||||
fill: var(--hp-accent);
|
||||
opacity: 0.75;
|
||||
@@ -495,6 +840,43 @@ export const cardStyles = css`
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* RGB lights: the bulb takes the light's actual color */
|
||||
.dev.rgb ha-icon { color: var(--light-color); }
|
||||
.dev.rgb.on {
|
||||
box-shadow: 0 0 10px var(--light-color);
|
||||
border-color: var(--light-color);
|
||||
background: var(--hp-bg);
|
||||
color: var(--light-color);
|
||||
}
|
||||
/* alarms pulse red over everything */
|
||||
.dev.alarm::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: calc(var(--icon-size, 2.5cqw) * -0.35);
|
||||
border: 3px solid #f25a4a;
|
||||
border-radius: 50%;
|
||||
animation: hp-alarm 1s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes hp-alarm {
|
||||
0% { transform: scale(0.7); opacity: 1; }
|
||||
100% { transform: scale(1.25); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dev.alarm::after { animation: none; opacity: 0.9; }
|
||||
}
|
||||
.dev .newdot {
|
||||
position: absolute;
|
||||
top: calc(var(--icon-size, 2.5cqw) * -0.12);
|
||||
right: calc(var(--icon-size, 2.5cqw) * -0.12);
|
||||
width: calc(var(--icon-size, 2.5cqw) * 0.34);
|
||||
height: calc(var(--icon-size, 2.5cqw) * 0.34);
|
||||
border-radius: 50%;
|
||||
background: #f0301f;
|
||||
border: 2px solid var(--card-background-color, var(--hp-bg));
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.devlayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -515,7 +897,7 @@ export const cardStyles = css`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--hp-txt);
|
||||
cursor: grab;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
|
||||
@@ -528,9 +910,8 @@ export const cardStyles = css`
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
}
|
||||
.dev:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.stage.mode-devices .dev { cursor: grab; }
|
||||
.stage.mode-devices .dev:active { cursor: grabbing; }
|
||||
.dev:hover {
|
||||
background: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
@@ -616,9 +997,10 @@ export const cardStyles = css`
|
||||
}
|
||||
.tab .tabedit {
|
||||
--mdc-icon-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 6px;
|
||||
opacity: 0.4;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tab:hover .tabedit {
|
||||
opacity: 0.9;
|
||||
@@ -833,6 +1215,42 @@ export const cardStyles = css`
|
||||
.pdftag .x:hover {
|
||||
color: #ff7a5c;
|
||||
}
|
||||
.entlist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.entrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--secondary-background-color, rgba(128, 128, 128, 0.12));
|
||||
}
|
||||
.entrow ha-icon { --mdc-icon-size: 20px; color: var(--hp-muted); }
|
||||
.entrow.on ha-icon { color: var(--hp-accent); }
|
||||
.entrow .en { flex: 1; font-size: 13px; }
|
||||
.entrow .ev { font-size: 13px; color: var(--hp-muted); }
|
||||
.entbtn {
|
||||
min-width: 74px;
|
||||
min-height: 32px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--hp-muted);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--hp-txt);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.entbtn.on {
|
||||
background: var(--hp-accent);
|
||||
border-color: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
font-weight: 600;
|
||||
}
|
||||
.inforow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user