mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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,13 @@
|
||||
Thanks for your interest! The project is one HACS package: a storage **integration**
|
||||
(`custom_components/houseplan/`, Python) and a **Lovelace card** (`src/`, TypeScript + Lit).
|
||||
|
||||
## 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,22 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
+66
-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,22 @@ 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) — для
|
||||
развёрнутых обсуждений.
|
||||
|
||||
Версия видна в консоли браузера при загрузке (`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)
|
||||
|
||||
@@ -9,9 +9,11 @@ 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"
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.31.2"
|
||||
VERSION = "1.44.2"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -18,7 +18,7 @@ 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 .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
|
||||
from .store import get_entry
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
@@ -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."""
|
||||
@@ -99,5 +152,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.31.2"
|
||||
"version": "1.44.2"
|
||||
}
|
||||
|
||||
@@ -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,11 +47,31 @@ 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))
|
||||
|
||||
@@ -68,6 +88,20 @@ ROOM_SCHEMA = vol.All(
|
||||
vol.Required("id"): str,
|
||||
vol.Required("name"): str,
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
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"): vol.Coerce(float),
|
||||
vol.Optional("y"): vol.Coerce(float),
|
||||
vol.Optional("w"): vol.Coerce(float),
|
||||
@@ -84,7 +118,7 @@ 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,
|
||||
@@ -92,10 +126,34 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
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,
|
||||
@@ -104,7 +162,8 @@ SPACE_SCHEMA = vol.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.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.Schema(
|
||||
{
|
||||
@@ -144,6 +203,9 @@ 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),
|
||||
@@ -158,10 +220,11 @@ 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(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""House Plan WS commands: layout, space configuration, plan uploads."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from pathlib import Path
|
||||
@@ -13,7 +15,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
PLANS_DIR, PLANS_URL,
|
||||
CONTENT_URL, PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
@@ -22,6 +24,9 @@ from .validation import (
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register(hass: HomeAssistant) -> None:
|
||||
"""Register the WS commands."""
|
||||
@@ -32,6 +37,8 @@ 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)
|
||||
|
||||
|
||||
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
||||
@@ -48,8 +55,17 @@ def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | No
|
||||
|
||||
|
||||
def _check_write(hass: HomeAssistant, connection) -> bool:
|
||||
"""May this connection write?
|
||||
|
||||
Fails CLOSED (audit B2): 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". Previously this returned True and ws_plan_set,
|
||||
which never touches the runtime helper, accepted uploads in that window.
|
||||
"""
|
||||
entry = get_entry(hass)
|
||||
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
if entry is None:
|
||||
return bool(getattr(connection.user, "is_admin", False))
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
return connection.user.is_admin if admin_only else True
|
||||
|
||||
|
||||
@@ -68,11 +84,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 +106,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 +141,113 @@ 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/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",
|
||||
@@ -171,6 +311,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",
|
||||
@@ -234,5 +383,5 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
|
||||
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}"}
|
||||
msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{space_id}.{msg['ext']}?v={mtime}"}
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
@@ -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 = {};
|
||||
@@ -47,5 +47,5 @@ const res = await page.evaluate(async () => {
|
||||
out.delroomIgnored = !confirmCalled;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
checkAll(res);
|
||||
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;
|
||||
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);
|
||||
|
||||
@@ -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 = {};
|
||||
@@ -6,7 +6,7 @@ const res = await page.evaluate(async () => {
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const tabs = () => [...sr().querySelectorAll('.modetab')];
|
||||
// 1) две вкладки, Просмотра нет, крестиков в неактивных нет
|
||||
out.twoTabs = tabs().length === 2;
|
||||
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';
|
||||
@@ -23,7 +23,7 @@ const res = await page.evaluate(async () => {
|
||||
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;
|
||||
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) крестик на панели → Просмотр
|
||||
@@ -35,5 +35,8 @@ const res = await page.evaluate(async () => {
|
||||
out.tabCrossWorks = c._mode === 'view';
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"labels": ["Plan editor", "Device editor", "Background editor"],
|
||||
});
|
||||
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 = {};
|
||||
@@ -31,5 +31,5 @@ const res = await page.evaluate(async () => {
|
||||
await esc(); out.undoPointStillWorks = c._path.length === 1;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
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);
|
||||
@@ -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 = {};
|
||||
@@ -31,5 +31,8 @@ const res = await page.evaluate(async () => {
|
||||
out.addInPlan = !!sr().querySelector('.tab.tabadd');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на 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);
|
||||
@@ -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 = {};
|
||||
@@ -14,5 +14,5 @@ const res = await page.evaluate(async () => {
|
||||
out.opensInView = !!c._settingsDialog;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res));
|
||||
await browser.close();
|
||||
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);
|
||||
@@ -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 = {};
|
||||
@@ -43,5 +43,10 @@ const res = await page.evaluate(async () => {
|
||||
c._openingDialog = null; c._setMode('view');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на 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);
|
||||
@@ -1,10 +1,11 @@
|
||||
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 = {};
|
||||
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
|
||||
@@ -47,5 +48,5 @@ const res = await page.evaluate(async () => {
|
||||
}
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
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);
|
||||
@@ -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 = {};
|
||||
@@ -14,11 +14,11 @@ const res = await page.evaluate(async () => {
|
||||
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 }; c.requestUpdate(); await c.updateComplete;
|
||||
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;
|
||||
});
|
||||
console.log(JSON.stringify(res));
|
||||
await browser.close();
|
||||
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) => {
|
||||
@@ -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);
|
||||
@@ -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 = {};
|
||||
@@ -28,5 +28,5 @@ const res = await page.evaluate(async () => {
|
||||
out.stillKnown = c._serverCfg.settings.known_devices.includes('d_new');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
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,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);
|
||||
@@ -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 = {};
|
||||
@@ -28,5 +28,8 @@ const res = await page.evaluate(async () => {
|
||||
out.outageSafe = sr().querySelectorAll('.dev.alarm').length === 0;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"alarmCount": 1,
|
||||
});
|
||||
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 = {};
|
||||
@@ -57,5 +57,11 @@ const res = await page.evaluate(async () => {
|
||||
out.dragKeepsScale = c._layout['rl_' + room.id].k <= 3 && c._layout['rl_' + room.id].k >= 2.9;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на 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);
|
||||
@@ -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,5 +1,5 @@
|
||||
// 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];
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {};
|
||||
@@ -31,5 +31,9 @@ const res = await page.evaluate(async () => {
|
||||
c._markerDialog = null;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на 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);
|
||||
+11
-3
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
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 = {};
|
||||
@@ -34,5 +34,13 @@ const res = await page.evaluate(async () => {
|
||||
return out;
|
||||
});
|
||||
await page.screenshot({ path: '/tmp/ux_dialog.png' });
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"filledClass": 1,
|
||||
"unfilled": 3,
|
||||
"tipTemp": 22.4,
|
||||
"fillRadios": 5,
|
||||
"tempInputs": 2,
|
||||
"dialogWidth": 502,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
+790
-199
File diff suppressed because one or more lines are too long
Vendored
+790
-199
File diff suppressed because one or more lines are too long
@@ -212,3 +212,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,408 @@
|
||||
# Changelog
|
||||
|
||||
## 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,
|
||||
|
||||
@@ -96,3 +96,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.
|
||||
|
||||
+15
-1
@@ -33,7 +33,7 @@ Editors are admin-only tools and must never leak interactions into View
|
||||
|---|---|---|
|
||||
| J1 | "Show the whole home and what's happening right now" — live spatial overview: device states, room fills (light/temp/LQI), values, multi-floor tabs | **Closed** |
|
||||
| J2 | "Something is wrong — show me *where*" — leak/smoke/gas pulse, open doors/windows, unlocked locks, red dot on devices HA added silently | **Closed** |
|
||||
| J3 | "Let me act on the obvious right from the plan" — tap-to-toggle for safe domains, info cards, guarded lock action (explicit button only, never a plan tap) | **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** |
|
||||
@@ -52,6 +52,17 @@ Editors are admin-only tools and must never leak interactions into View
|
||||
## 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).
|
||||
|
||||
@@ -91,6 +102,9 @@ Editors are admin-only tools and must never leak interactions into View
|
||||
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
|
||||
|
||||
+34
-9
@@ -9,18 +9,43 @@
|
||||
> (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-24)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.27.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.41.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — `main` = 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.41.0** via direct copy (HACS custom repo also installed) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| Tests | 111 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)
|
||||
|
||||
|
||||
+282
-73
@@ -3,9 +3,53 @@
|
||||
> **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]
|
||||
|
||||
## Environments matrix
|
||||
|
||||
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
|
||||
@@ -23,39 +67,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
|
||||
- [ ] 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 [auto]
|
||||
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 [auto]
|
||||
Plan so it does not fight editing [manual]
|
||||
- [ ] Device icons in View show a pointer cursor (no grab); grab only in the
|
||||
Devices mode [auto]
|
||||
Devices mode [manual]
|
||||
- [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along
|
||||
walls, click (any tool) opens its properties [auto]
|
||||
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
|
||||
@@ -64,30 +111,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)
|
||||
|
||||
@@ -110,71 +157,233 @@ 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]
|
||||
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 [auto]
|
||||
(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 [auto]; reduced-motion static
|
||||
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]
|
||||
- [ ] 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]
|
||||
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]
|
||||
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]
|
||||
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 [auto]
|
||||
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]
|
||||
(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]
|
||||
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]
|
||||
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]
|
||||
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]
|
||||
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto]
|
||||
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) ★
|
||||
|
||||
@@ -183,29 +392,29 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] 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 [auto]
|
||||
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
|
||||
|
||||
@@ -213,7 +422,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 ★
|
||||
@@ -225,13 +434,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
|
||||
@@ -249,7 +458,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`.
|
||||
@@ -259,7 +468,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
|
||||
@@ -275,14 +484,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)
|
||||
|
||||
|
||||
+20
-4
@@ -12,10 +12,16 @@ 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:
|
||||
|
||||
**[ 📐 Plan editor ] [ 🔧 Device editor ]** — View has NO tab (since v1.30.2).
|
||||
**[ 📐 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 implicit default state: no editor tab is active. It is the only
|
||||
state after every load (edit modes are never restored across reloads).
|
||||
- **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
|
||||
@@ -29,7 +35,7 @@ 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 (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 tap → HA area, hover tooltips (name, temperature, signal).
|
||||
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),
|
||||
@@ -81,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.31.2",
|
||||
"version": "1.44.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"
|
||||
|
||||
+48
-9
@@ -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). */
|
||||
@@ -343,6 +356,32 @@ 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,
|
||||
|
||||
+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}
|
||||
|
||||
+1777
-150
File diff suppressed because it is too large
Load Diff
+79
-10
@@ -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",
|
||||
@@ -115,7 +114,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 +122,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 +176,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",
|
||||
@@ -251,7 +246,6 @@
|
||||
"title.close_editor": "Close editor (back to view)",
|
||||
"devbar.add": "Add",
|
||||
"devbar.show_all": "Show all",
|
||||
"devbar.reset": "Reset",
|
||||
"devbar.rules": "Icon rules",
|
||||
"space.roomcard_section": "Room card shows:",
|
||||
"space.label_temp": "Temperature",
|
||||
@@ -260,5 +254,80 @@
|
||||
"space.label_light": "Lights on/off",
|
||||
"roomcard.light_on": "On",
|
||||
"roomcard.light_off": "Off",
|
||||
"roomcard.light_partial": "{on} of {total}"
|
||||
"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}"
|
||||
}
|
||||
|
||||
+80
-11
@@ -22,7 +22,6 @@
|
||||
"title.zoom_reset": "Сбросить масштаб",
|
||||
"title.add_device": "Добавить устройство на план",
|
||||
"title.show_all": "Показывать все устройства зоны (без курирования)",
|
||||
"title.reset_layout": "Сбросить позиции значков к авто-раскладке",
|
||||
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
@@ -115,7 +114,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 +122,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 +176,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 уже знает эти этажи. Отметьте, какие превратить в пространства плана — далее для каждого попросим картинку плана. Комнаты затем обводятся вручную по плану.",
|
||||
@@ -251,7 +246,6 @@
|
||||
"title.close_editor": "Закрыть редактор (вернуться к просмотру)",
|
||||
"devbar.add": "Добавить",
|
||||
"devbar.show_all": "Показать все",
|
||||
"devbar.reset": "Сброс",
|
||||
"devbar.rules": "Правила иконок",
|
||||
"space.roomcard_section": "В карточке комнаты:",
|
||||
"space.label_temp": "Температура",
|
||||
@@ -260,5 +254,80 @@
|
||||
"space.label_light": "Свет вкл/выкл",
|
||||
"roomcard.light_on": "Вкл",
|
||||
"roomcard.light_off": "Выкл",
|
||||
"roomcard.light_partial": "{on} из {total}"
|
||||
"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}"
|
||||
}
|
||||
|
||||
+497
-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,8 @@ 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;
|
||||
@@ -518,10 +636,13 @@ 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,
|
||||
@@ -547,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 = {
|
||||
@@ -558,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;
|
||||
@@ -701,6 +827,353 @@ export function lightColorOf(state: any): string | null {
|
||||
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.
|
||||
*/
|
||||
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']);
|
||||
|
||||
|
||||
+2
-1
@@ -46,7 +46,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' },
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
+310
-3
@@ -189,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;
|
||||
@@ -364,7 +364,7 @@ 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 * var(--rl-scale, 1));
|
||||
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;
|
||||
@@ -377,11 +377,57 @@ export const cardStyles = css`
|
||||
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: 0.62em;
|
||||
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;
|
||||
@@ -396,6 +442,86 @@ export const cardStyles = css`
|
||||
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;
|
||||
@@ -418,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%);
|
||||
@@ -432,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;
|
||||
@@ -445,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;
|
||||
}
|
||||
@@ -944,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;
|
||||
|
||||
+28
-1
@@ -1,6 +1,19 @@
|
||||
/** Shared types of the House Plan card. */
|
||||
|
||||
export interface RoomCfg {
|
||||
/** Rooms this one has an OPEN (virtual) boundary with - light flows through. */
|
||||
open_to?: string[] | null;
|
||||
/** Room-level settings (tier 3 of 4: global > space > ROOM > device). */
|
||||
settings?: {
|
||||
/** Fill override; unset = inherit the space fill mode. */
|
||||
fill_mode?: 'none' | 'lqi' | 'light' | 'temp' | null;
|
||||
/** 'device:<id>' or 'entity:<eid>'; unset = average over the room sensors. */
|
||||
temp_source?: string | null;
|
||||
hum_source?: string | null;
|
||||
/** Font multipliers for THIS room's card (0.5-3, unset = 1). */
|
||||
name_scale?: number | null;
|
||||
label_scale?: number | null;
|
||||
} | null;
|
||||
id?: string;
|
||||
name: string;
|
||||
area: string | null;
|
||||
@@ -44,6 +57,16 @@ export interface Marker {
|
||||
ripple_size?: number | null; // max ring diameter, in icon diameters (default 3)
|
||||
size?: number | null; // icon size multiplier (default 1)
|
||||
angle?: number | null; // icon rotation, degrees
|
||||
/** Entities this icon toggles as a group (wall switch → its lights). */
|
||||
controls?: string[] | null;
|
||||
/** Per-source glow radius in cm (glow fill); null = the global default. */
|
||||
glow_radius_cm?: number | null;
|
||||
/**
|
||||
* Treat this marker as a light source in the glow fill even when it has no
|
||||
* light.* entity (a smart switch driving dumb fixtures — field request).
|
||||
* null/undefined = auto: any light.* entity of the device.
|
||||
*/
|
||||
is_light?: boolean | null;
|
||||
}
|
||||
|
||||
/** A door or window: plan geometry (normalized coords), optionally live via entities. */
|
||||
@@ -102,5 +125,9 @@ export interface CardConfig {
|
||||
live_states?: boolean;
|
||||
show_signal?: boolean;
|
||||
language?: string; // 'en' | 'ru' | '' (auto — HA profile)
|
||||
tap_action?: string; // 'info' (default) | 'more-info' | 'toggle'
|
||||
tap_action?: string; // legacy, ignored since v1.38.1
|
||||
/** Wall-device (kiosk) mode: no header, no editors, swipe between spaces. */
|
||||
kiosk?: boolean;
|
||||
/** Kiosk auto-carousel: seconds between space switches, 0/undefined = off. */
|
||||
cycle?: number;
|
||||
}
|
||||
|
||||
+52
-2
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats } from '../test-build/devices.js';
|
||||
import { compileIconRules } from '../test-build/rules.js';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue } from '../test-build/devices.js';
|
||||
import { compileIconRules, iconFor } from '../test-build/rules.js';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
function mkHass({ devices = {}, entities = {}, states = {}, areas = {} } = {}) {
|
||||
@@ -309,3 +309,53 @@ test('areaHum: averages climate sensors only, integer %', () => {
|
||||
assert.equal(areaHum(hass, devs, 'living'), 44);
|
||||
assert.equal(areaHum(hass, devs, 'nothing'), null);
|
||||
});
|
||||
|
||||
test('primaryEntity: hidden light beats visible config switch (grouped lamps)', () => {
|
||||
const hass = {
|
||||
entities: {
|
||||
'light.lamp': { hidden: true },
|
||||
'switch.lamp_do_not_disturb': { entity_category: 'config' },
|
||||
'button.lamp_identify': { entity_category: 'diagnostic' },
|
||||
},
|
||||
states: {},
|
||||
};
|
||||
assert.equal(
|
||||
primaryEntity(hass, ['switch.lamp_do_not_disturb', 'light.lamp', 'button.lamp_identify'], 'mdi:lightbulb'),
|
||||
'light.lamp',
|
||||
);
|
||||
// видимая сущность того же домена всё равно приоритетнее скрытой
|
||||
const hass2 = {
|
||||
entities: { 'light.a': { hidden: true }, 'light.b': {} },
|
||||
states: {},
|
||||
};
|
||||
assert.equal(primaryEntity(hass2, ['light.a', 'light.b'], 'mdi:lightbulb'), 'light.b');
|
||||
});
|
||||
|
||||
test('icon rules: soundbars vs smart speakers (v1.40.2)', () => {
|
||||
assert.equal(iconFor('Sony soundbar', ''), 'mdi:soundbar');
|
||||
assert.equal(iconFor('Яндекс Станция Мини', ''), 'mdi:speaker');
|
||||
assert.equal(iconFor('Колонка Алисы', ''), 'mdi:speaker');
|
||||
assert.equal(iconFor('Kitchen speaker', ''), 'mdi:speaker');
|
||||
});
|
||||
|
||||
test('sourceValue: explicit entity and device sources (tier 3)', () => {
|
||||
const hass = {
|
||||
states: {
|
||||
'sensor.custom_temp': { state: '23.456' },
|
||||
'sensor.custom_hum': { state: '47.8' },
|
||||
'sensor.bad': { state: 'unknown' },
|
||||
'sensor.dev_t': { state: '21.5', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
},
|
||||
entities: {
|
||||
'sensor.dev_t': { device_id: 'dev1' },
|
||||
'sensor.custom_temp': {}, 'sensor.custom_hum': {}, 'sensor.bad': {},
|
||||
},
|
||||
};
|
||||
assert.equal(sourceValue(hass, 'entity:sensor.custom_temp', 'temp'), 23.5);
|
||||
assert.equal(sourceValue(hass, 'entity:sensor.custom_hum', 'hum'), 48);
|
||||
assert.equal(sourceValue(hass, 'entity:sensor.bad', 'temp'), null);
|
||||
assert.equal(sourceValue(hass, 'device:dev1', 'temp'), 21.5);
|
||||
assert.equal(sourceValue(hass, 'device:nope', 'temp'), null);
|
||||
assert.equal(sourceValue(hass, '', 'temp'), null);
|
||||
assert.equal(sourceValue(hass, 'garbage', 'temp'), null);
|
||||
});
|
||||
|
||||
+344
-7
@@ -3,6 +3,17 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||
splitRoomPath, polyContainsPoly, islandsOf,
|
||||
kelvinToRgb, glowColorOf, doorSector, hasRoomBehind,
|
||||
controlsAction, isControllable,
|
||||
sharedBoundary, openZoneOf, distToSegment,
|
||||
outlineWithout,
|
||||
alignGuides, segmentAngle, is45,
|
||||
swipeTarget, clampScale,
|
||||
migratePdfUrls,
|
||||
roomFillModeOf,
|
||||
contentUrl,
|
||||
interiorPoint,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||
} from '../test-build/logic.js';
|
||||
@@ -170,8 +181,9 @@ test('icon rules: device_class fallback', () => {
|
||||
assert.equal(iconFromDeviceClasses([]), null);
|
||||
});
|
||||
|
||||
test('tap action: defaults to info', () => {
|
||||
assert.equal(resolveTapAction(undefined, undefined, 'light'), 'info');
|
||||
test('tap action: defaults — info everywhere except pure lights (v1.39.0)', () => {
|
||||
assert.equal(resolveTapAction(undefined, undefined, 'light'), 'toggle'); // лампы кликабельны из коробки
|
||||
assert.equal(resolveTapAction(undefined, undefined, 'switch'), 'info');
|
||||
assert.equal(resolveTapAction(null, 'info', 'switch'), 'info');
|
||||
assert.equal(resolveTapAction(null, 'more-info', 'sensor'), 'more-info');
|
||||
});
|
||||
@@ -324,14 +336,32 @@ test('roomsOverlap: sharing a wall or a corner is legal; real overlap is not', (
|
||||
assert.ok(roomsOverlap(SQ, [[1, 1], [3, 1], [3, 3], [1, 3]]));
|
||||
});
|
||||
|
||||
test('roomsOverlap: nested, identical and enclosing outlines all count as overlap', () => {
|
||||
assert.ok(roomsOverlap(SQ, [[0.5, 0.5], [1.5, 0.5], [1.5, 1.5], [0.5, 1.5]])); // nested
|
||||
assert.ok(roomsOverlap(SQ, SQ)); // duplicate
|
||||
// drawn AROUND an existing room: every vertex outside, no vertex of ours inside it
|
||||
assert.ok(roomsOverlap([[-1, -1], [3, -1], [3, 3], [-1, 3]], SQ));
|
||||
test('roomsOverlap v1.34: nesting is legal (islands), duplicates and crossings are not', () => {
|
||||
// nested & enclosing — legal since island rooms
|
||||
assert.ok(!roomsOverlap(SQ, [[0.5, 0.5], [1.5, 0.5], [1.5, 1.5], [0.5, 1.5]]));
|
||||
assert.ok(!roomsOverlap([[-1, -1], [3, -1], [3, 3], [-1, 3]], SQ));
|
||||
// a duplicate outline is still an overlap
|
||||
assert.ok(roomsOverlap(SQ, SQ));
|
||||
// a cross: no vertex of either lies inside the other, but the edges cross
|
||||
assert.ok(roomsOverlap([[0, 0.5], [3, 0.5], [3, 1.5], [0, 1.5]],
|
||||
[[0.5, -1], [1.5, -1], [1.5, 3], [0.5, 3]]));
|
||||
// genuine partial overlap is still rejected
|
||||
assert.ok(roomsOverlap(SQ, [[1, 1], [3, 1], [3, 3], [1, 3]]));
|
||||
});
|
||||
|
||||
test('polyContainsPoly & islandsOf', () => {
|
||||
const outer = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const col = [[4, 4], [6, 4], [6, 6], [4, 6]];
|
||||
const tiny = [[4.5, 4.5], [5, 4.5], [5, 5], [4.5, 5]]; // внутри col
|
||||
const partial = [[8, 8], [12, 8], [12, 12], [8, 12]];
|
||||
assert.ok(polyContainsPoly(outer, col));
|
||||
assert.ok(!polyContainsPoly(col, outer));
|
||||
assert.ok(!polyContainsPoly(outer, outer)); // дубликат — не вложенность
|
||||
assert.ok(!polyContainsPoly(outer, partial));
|
||||
// прямые острова: col — да; tiny — нет (он остров col, не outer)
|
||||
const isl = islandsOf(outer, [col, tiny, partial]);
|
||||
assert.deepEqual(isl, [col]);
|
||||
assert.deepEqual(islandsOf(col, [tiny]), [tiny]);
|
||||
});
|
||||
|
||||
test('roomPoly: polygon rooms as-is, legacy rect rooms as four corners', () => {
|
||||
@@ -544,3 +574,310 @@ test('spaceDisplayOf: room-card metric flags default to off', () => {
|
||||
const d1 = spaceDisplayOf({ plan_url: 'x', settings: { label_temp: true, label_light: true } });
|
||||
assert.deepEqual([d1.labelTemp, d1.labelHum, d1.labelLqi, d1.labelLight], [true, false, false, true]);
|
||||
});
|
||||
|
||||
test('splitRoomPath: polyline cut of a square', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
// Г-образный разрез: верхняя стена (4,0) → внутрь (4,6) → правая стена (10,6)
|
||||
const parts = splitRoomPath(sq, [[4, 0], [4, 6], [10, 6]], 1e-6);
|
||||
assert.ok(parts);
|
||||
const [p1, p2] = parts;
|
||||
const area = (p) => Math.abs(p.reduce((a, _, i) => {
|
||||
const [x1, y1] = p[i], [x2, y2] = p[(i + 1) % p.length];
|
||||
return a + x1 * y2 - x2 * y1;
|
||||
}, 0)) / 2;
|
||||
assert.ok(Math.abs(area(p1) + area(p2) - 100) < 1e-6);
|
||||
// меньшая часть — прямоугольник 6x6 минус... фактически площадь 4*6+... проверим что обе > 0
|
||||
assert.ok(area(p1) > 0 && area(p2) > 0);
|
||||
});
|
||||
|
||||
test('splitRoomPath: two points == classic straight chord', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const parts = splitRoomPath(sq, [[5, 0], [5, 10]], 1e-6);
|
||||
assert.ok(parts);
|
||||
});
|
||||
|
||||
test('splitRoomPath rejects bad paths', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
// промежуточная точка снаружи
|
||||
assert.equal(splitRoomPath(sq, [[4, 0], [4, -3], [10, 6]], 1e-6), null);
|
||||
// сегмент пересекает стену (выходит и возвращается)
|
||||
assert.equal(splitRoomPath(sq, [[4, 0], [14, 5], [10, 6]], 1e-6), null);
|
||||
// конец не на стене
|
||||
assert.equal(splitRoomPath(sq, [[4, 0], [5, 5]], 1e-6), null);
|
||||
// самопересечение пути
|
||||
assert.equal(splitRoomPath(sq, [[2, 0], [8, 8], [8, 2], [2, 8], [0, 4]], 1e-6), null);
|
||||
// менее двух точек
|
||||
assert.equal(splitRoomPath(sq, [[4, 0]], 1e-6), null);
|
||||
});
|
||||
|
||||
test('kelvinToRgb: warm is orange-ish, cool is blue-ish white', () => {
|
||||
const warm = kelvinToRgb(2700);
|
||||
assert.equal(warm[0], 255);
|
||||
assert.ok(warm[2] < 200 && warm[1] < 230);
|
||||
const cool = kelvinToRgb(6600);
|
||||
assert.ok(cool[0] > 245 && cool[1] > 240 && cool[2] > 245);
|
||||
// clamps
|
||||
assert.ok(kelvinToRgb(100)[0] === 255);
|
||||
});
|
||||
|
||||
test('glowColorOf: rgb wins, then color temp, then fallback; off = null', () => {
|
||||
assert.equal(glowColorOf({ state: 'off', attributes: {} }, '#fff'), null);
|
||||
assert.equal(glowColorOf(null, '#fff'), null);
|
||||
const rgb = glowColorOf({ state: 'on', attributes: { rgb_color: [10, 20, 30], brightness: 128 } }, '#fff');
|
||||
assert.equal(rgb.c, 'rgb(10, 20, 30)');
|
||||
assert.ok(Math.abs(rgb.bri - 0.5) < 0.01);
|
||||
const ct = glowColorOf({ state: 'on', attributes: { color_temp_kelvin: 2700 } }, '#fff');
|
||||
assert.ok(ct.c.startsWith('rgb(255'));
|
||||
const mireds = glowColorOf({ state: 'on', attributes: { color_temp: 370 } }, '#fff'); // ~2700K
|
||||
assert.ok(mireds.c.startsWith('rgb(255'));
|
||||
const fb = glowColorOf({ state: 'on', attributes: {} }, '#abcdef');
|
||||
assert.equal(fb.c, '#abcdef');
|
||||
assert.equal(fb.bri, 1);
|
||||
});
|
||||
|
||||
test('doorSector: sector through a door, clamped and guarded', () => {
|
||||
const s = [0, 0];
|
||||
const sec = doorSector(s, [10, -2], [10, 2], 50);
|
||||
assert.ok(sec && sec.length === 10 + 0); // вершина + 9 точек дуги
|
||||
assert.deepEqual(sec[0], [0, 0]);
|
||||
for (const p of sec.slice(1)) {
|
||||
const d = Math.hypot(p[0], p[1]);
|
||||
assert.ok(Math.abs(d - 50) < 1e-6);
|
||||
assert.ok(p[0] > 0); // сектор смотрит в сторону двери
|
||||
}
|
||||
// дверь за радиусом
|
||||
assert.equal(doorSector(s, [60, -2], [60, 2], 50), null);
|
||||
// источник на краю двери
|
||||
assert.equal(doorSector(s, [0, 0], [10, 2], 50), null);
|
||||
});
|
||||
|
||||
test('hasRoomBehind: neighbour room yes, street no', () => {
|
||||
const neighbour = [[10, -5], [20, -5], [20, 5], [10, 5]];
|
||||
// дверь в стене x=10 (стена вертикальна: угол 90°), источник слева в (5,0)
|
||||
assert.ok(hasRoomBehind([10, 0], 90, [5, 0], [neighbour], 1));
|
||||
// за дверью пусто
|
||||
assert.ok(!hasRoomBehind([10, 0], 90, [5, 0], [], 1));
|
||||
assert.ok(!hasRoomBehind([10, 0], 90, [5, 0], [[[30, 30], [40, 30], [40, 40], [30, 40]]], 1));
|
||||
});
|
||||
|
||||
test('controlsAction: HA-group semantics', () => {
|
||||
assert.equal(controlsAction(['off', 'off']), 'turn_on');
|
||||
assert.equal(controlsAction(['off', 'on']), 'turn_off');
|
||||
assert.equal(controlsAction(['on', 'on']), 'turn_off');
|
||||
assert.equal(controlsAction([undefined, 'off']), 'turn_on');
|
||||
assert.equal(controlsAction([]), 'turn_on');
|
||||
});
|
||||
|
||||
test('isControllable: lights and switches only', () => {
|
||||
assert.ok(isControllable('light.kitchen'));
|
||||
assert.ok(isControllable('switch.pump'));
|
||||
assert.ok(!isControllable('lock.front'));
|
||||
assert.ok(!isControllable('cover.gate'));
|
||||
assert.ok(!isControllable('alarm_control_panel.home'));
|
||||
});
|
||||
|
||||
test('sharedBoundary: exact, partial-collinear and none', () => {
|
||||
const A = [[0, 0], [2, 0], [2, 2], [0, 2]];
|
||||
// точное общее ребро x=2
|
||||
const B = [[2, 0], [4, 0], [4, 2], [2, 2]];
|
||||
const s1 = sharedBoundary(A, B);
|
||||
assert.equal(s1.length >= 1, true);
|
||||
const len = (s) => Math.hypot(s[2] - s[0], s[3] - s[1]);
|
||||
assert.ok(Math.abs(Math.max(...s1.map(len)) - 2) < 1e-6);
|
||||
// частичное коллинеарное наложение (дачный случай): стена соседа длиннее
|
||||
const C = [[2, -1], [4, -1], [4, 3], [2, 3]];
|
||||
const s2 = sharedBoundary(A, C);
|
||||
assert.ok(Math.abs(Math.max(...s2.map(len)) - 2) < 1e-6); // перекрытие = наша стена
|
||||
// нет наложения
|
||||
assert.equal(sharedBoundary(A, [[5, 5], [6, 5], [6, 6], [5, 6]]).length, 0);
|
||||
// перпендикулярное касание — не граница
|
||||
assert.equal(sharedBoundary(A, [[2, 2], [3, 2], [3, 3], [2, 3]]).filter((s) => len(s) > 1e-6).length, 0);
|
||||
});
|
||||
|
||||
test('openZoneOf: transitive, either-direction links', () => {
|
||||
const rooms = [
|
||||
{ id: 'a', open_to: ['b'] },
|
||||
{ id: 'b' }, // связь только со стороны a
|
||||
{ id: 'c', open_to: ['b'] }, // b↔c со стороны c
|
||||
{ id: 'd' }, // не связан
|
||||
];
|
||||
const z = openZoneOf('a', rooms);
|
||||
assert.deepEqual([...z].sort(), ['a', 'b', 'c']);
|
||||
assert.deepEqual([...openZoneOf('d', rooms)], ['d']);
|
||||
});
|
||||
|
||||
test('distToSegment', () => {
|
||||
assert.equal(distToSegment([0, 5], [0, 0, 0, 10]), 0);
|
||||
assert.equal(distToSegment([3, 5], [0, 0, 0, 10]), 3);
|
||||
assert.ok(Math.abs(distToSegment([-3, -4], [0, 0, 0, 10]) - 5) < 1e-9);
|
||||
});
|
||||
|
||||
test('outlineWithout: removes the cut stretch, keeps the rest', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
// вырез середины нижней стены: 3..7
|
||||
const pieces = outlineWithout(sq, [[3, 0, 7, 0]]);
|
||||
const len = (s) => Math.hypot(s[2] - s[0], s[3] - s[1]);
|
||||
const total = pieces.reduce((a, s) => a + len(s), 0);
|
||||
assert.ok(Math.abs(total - (40 - 4)) < 1e-6);
|
||||
// куски нижней стены: 0..3 и 7..10
|
||||
const bottom = pieces.filter((s) => s[1] === 0 && s[3] === 0);
|
||||
assert.equal(bottom.length, 2);
|
||||
// вырез целого ребра
|
||||
const p2 = outlineWithout(sq, [[0, 0, 10, 0]]);
|
||||
assert.ok(Math.abs(p2.reduce((a, s) => a + len(s), 0) - 30) < 1e-6);
|
||||
// без вырезов — весь периметр
|
||||
assert.ok(Math.abs(outlineWithout(sq, []).reduce((a, s) => a + len(s), 0) - 40) < 1e-6);
|
||||
});
|
||||
|
||||
test('resolveTapAction: pure lights toggle by default (v1.39.0)', () => {
|
||||
assert.equal(resolveTapAction(null, null, 'light'), 'toggle');
|
||||
assert.equal(resolveTapAction('', undefined, 'light'), 'toggle');
|
||||
// явный выбор пользователя сильнее дефолта
|
||||
assert.equal(resolveTapAction('info', null, 'light'), 'info');
|
||||
// не-световые домены не трогаем
|
||||
assert.equal(resolveTapAction(null, null, 'switch'), 'info');
|
||||
assert.equal(resolveTapAction(null, null, 'sensor'), 'info');
|
||||
// замок никогда
|
||||
assert.equal(resolveTapAction('toggle', null, 'lock'), 'info');
|
||||
});
|
||||
|
||||
test('alignGuides: nearest per axis, indication only', () => {
|
||||
const cands = [[10, 50], [10, 90], [70, 20], [40, 40]];
|
||||
const g = alignGuides([10, 60], cands, 0.5);
|
||||
const gx = g.find((x) => x.axis === 'x');
|
||||
assert.ok(gx && gx.at === 10 && gx.from[1] === 50); // ближайший по Y из выровненных по X
|
||||
// выравнивание по Y
|
||||
const g2 = alignGuides([30, 20], cands, 0.5);
|
||||
const gy = g2.find((x) => x.axis === 'y');
|
||||
assert.ok(gy && gy.at === 20 && gy.from[0] === 70);
|
||||
// вне допуска — пусто
|
||||
assert.equal(alignGuides([11, 60], cands, 0.5).length, 0);
|
||||
// совпадающая точка не гид сама себе
|
||||
assert.equal(alignGuides([10, 50], [[10, 50]], 0.5).length, 0);
|
||||
// максимум два гида
|
||||
const g3 = alignGuides([10, 20], cands, 0.5);
|
||||
assert.ok(g3.length <= 2);
|
||||
});
|
||||
|
||||
test('segmentAngle & is45', () => {
|
||||
assert.equal(segmentAngle([0, 0], [10, 0]), 0);
|
||||
assert.equal(segmentAngle([0, 0], [0, 10]), 90);
|
||||
assert.equal(segmentAngle([0, 0], [10, 10]), 45);
|
||||
assert.equal(segmentAngle([0, 0], [-10, 0]), 180);
|
||||
assert.ok(is45(45) && is45(90) && is45(315) && is45(0));
|
||||
assert.ok(is45(44.8) && is45(45.3));
|
||||
assert.ok(!is45(30) && !is45(52));
|
||||
});
|
||||
|
||||
test('swipeTarget: kiosk swipe rules', () => {
|
||||
const ids = ['f1', 'f2', 'garden'];
|
||||
// влево → следующее, вправо → предыдущее, по кругу
|
||||
assert.equal(swipeTarget(-100, 5, 1, ids, 'f1'), 'f2');
|
||||
assert.equal(swipeTarget(100, 5, 1, ids, 'f1'), 'garden');
|
||||
assert.equal(swipeTarget(-100, 0, 1, ids, 'garden'), 'f1');
|
||||
// при зуме — не свайп
|
||||
assert.equal(swipeTarget(-100, 0, 2, ids, 'f1'), null);
|
||||
// короткий или диагональный жест — не свайп
|
||||
assert.equal(swipeTarget(-30, 0, 1, ids, 'f1'), null);
|
||||
assert.equal(swipeTarget(-80, 70, 1, ids, 'f1'), null);
|
||||
// одно пространство — некуда
|
||||
assert.equal(swipeTarget(-100, 0, 1, ['f1'], 'f1'), null);
|
||||
});
|
||||
|
||||
test('clampScale', () => {
|
||||
assert.equal(clampScale(2), 2);
|
||||
assert.equal(clampScale(9), 3);
|
||||
assert.equal(clampScale(0.1), 0.5);
|
||||
assert.equal(clampScale('x'), 1);
|
||||
assert.equal(clampScale(undefined, 1.5), 1.5);
|
||||
});
|
||||
|
||||
test('migratePdfUrls: only confirmed copies are rewritten (review CR-3)', () => {
|
||||
const pdfs = [
|
||||
{ name: 'a.pdf', url: '/houseplan_files/files/v_old1/a.pdf?v=1' },
|
||||
{ name: 'b.pdf', url: '/houseplan_files/files/v_old1/b.pdf?v=2' },
|
||||
];
|
||||
// сервер скопировал только a.pdf, причём переименовал из-за коллизии
|
||||
const out = migratePdfUrls(pdfs, 'v_old1', 'dev99', { 'a.pdf': 'a (2).pdf' });
|
||||
assert.equal(out[0].url, '/houseplan_files/files/dev99/a%20(2).pdf?v=1');
|
||||
assert.equal(out[1].url, pdfs[1].url, 'нескопированный файл ссылается на старую папку');
|
||||
// пустой маппинг = ничего не переносим
|
||||
assert.deepEqual(migratePdfUrls(pdfs, 'v_old1', 'dev99', {}).map((p) => p.url),
|
||||
pdfs.map((p) => p.url));
|
||||
});
|
||||
|
||||
test('migratePdfUrls: rebinding rewrites file urls', () => {
|
||||
const pdfs = [
|
||||
{ name: 'a.pdf', url: '/houseplan_files/files/v_old1/a.pdf?v=1' },
|
||||
{ name: 'b.pdf', url: '/houseplan_files/files/other/b.pdf?v=2' },
|
||||
];
|
||||
const out = migratePdfUrls(pdfs, 'v_old1', 'dev99');
|
||||
assert.equal(out[0].url, '/houseplan_files/files/dev99/a.pdf?v=1');
|
||||
assert.equal(out[1].url, pdfs[1].url); // чужие пути не трогаем
|
||||
// без смены id — как есть
|
||||
assert.equal(migratePdfUrls(pdfs, 'x', 'x'), pdfs);
|
||||
});
|
||||
|
||||
test('roomFillModeOf: tier-3 override beats the space, junk inherits', () => {
|
||||
assert.equal(roomFillModeOf('temp', { settings: { fill_mode: 'light' } }), 'light');
|
||||
assert.equal(roomFillModeOf('glow', { settings: { fill_mode: 'none' } }), 'none'); // выход из тьмы
|
||||
assert.equal(roomFillModeOf('temp', {}), 'temp');
|
||||
assert.equal(roomFillModeOf('temp', null), 'temp');
|
||||
assert.equal(roomFillModeOf('temp', { settings: { fill_mode: 'glow' } }), 'temp'); // glow нельзя выбрать per-room
|
||||
});
|
||||
|
||||
test('splitRoomPath: both ends on the SAME edge carve a niche (audit G1)', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const A = (p) => Math.round(polygonArea(p) * 1000) / 1000;
|
||||
// ИНВАРИАНТ разреза: части в сумме дают исходную площадь
|
||||
const invariant = (pts, label) => {
|
||||
const r = splitRoomPath(sq, pts);
|
||||
assert.ok(r, label + ': разрез должен приниматься');
|
||||
assert.ok(Math.abs(A(r[0]) + A(r[1]) - A(sq)) < 1e-6,
|
||||
label + ': сумма частей ' + (A(r[0]) + A(r[1])) + ' != ' + A(sq));
|
||||
return r;
|
||||
};
|
||||
const niche = invariant([[2, 0], [2, 3], [8, 3], [8, 0]], 'ниша снизу');
|
||||
assert.deepEqual([A(niche[0]), A(niche[1])].sort((x, y) => x - y), [18, 82]);
|
||||
invariant([[2, 10], [2, 7], [8, 7], [8, 10]], 'ниша сверху');
|
||||
invariant([[0, 2], [3, 2], [3, 8], [0, 8]], 'ниша слева');
|
||||
invariant([[8, 0], [8, 3], [2, 3], [2, 0]], 'обратный порядок точек');
|
||||
invariant([[1, 0], [3, 4], [5, 1], [7, 4], [9, 0]], 'зигзаг');
|
||||
// вырожденная ниша нулевой площади — отказ
|
||||
assert.equal(splitRoomPath(sq, [[2, 0], [2, 1e-9], [8, 0]]), null);
|
||||
// старые формы разрезов сохраняют инвариант
|
||||
invariant([[5, 0], [5, 10]], 'прямая хорда');
|
||||
invariant([[4, 0], [4, 6], [10, 6]], 'Г-образный');
|
||||
});
|
||||
|
||||
test('contentUrl: legacy static paths become authenticated ones (audit B1)', () => {
|
||||
assert.equal(contentUrl('/houseplan_files/plans/f1.svg?v=1'), '/api/houseplan/content/plans/_/f1.svg?v=1');
|
||||
assert.equal(contentUrl('/houseplan_files/files/dev1/a.pdf?v=2'), '/api/houseplan/content/files/dev1/a.pdf?v=2');
|
||||
// новые и внешние адреса не трогаем
|
||||
assert.equal(contentUrl('/api/houseplan/content/files/x/y.pdf'), '/api/houseplan/content/files/x/y.pdf');
|
||||
assert.equal(contentUrl('https://example.com/a.pdf'), 'https://example.com/a.pdf');
|
||||
assert.equal(contentUrl(''), '');
|
||||
assert.equal(contentUrl(null), '');
|
||||
});
|
||||
|
||||
test('interiorPoint / polyContainsPoly on concave rooms (audit G2)', () => {
|
||||
// U-образная комната: среднее вершин лежит СНАРУЖИ
|
||||
const u = [[0, 0], [10, 0], [10, 10], [9, 10], [9, 2], [1, 2], [1, 10], [0, 10]];
|
||||
const mean = [u.reduce((s, p) => s + p[0], 0) / u.length, u.reduce((s, p) => s + p[1], 0) / u.length];
|
||||
assert.equal(pointStrictlyInside(mean, u), false, 'предпосылка: среднее снаружи');
|
||||
const ip = interiorPoint(u);
|
||||
assert.ok(ip && pointStrictlyInside(ip, u), 'interiorPoint обязана быть внутри');
|
||||
// вложенность вогнутых теперь распознаётся
|
||||
const outer = [[-1, -1], [11, -1], [11, 11], [8.5, 11], [8.5, 2.5], [1.5, 2.5], [1.5, 11], [-1, 11]];
|
||||
assert.equal(polyContainsPoly(outer, u), true);
|
||||
assert.equal(roomsOverlap(outer, u), false);
|
||||
// дубликат по-прежнему НЕ вложенность
|
||||
assert.equal(polyContainsPoly(u, u), false);
|
||||
});
|
||||
|
||||
test('segKey: one wall, one key at any precision (audit G3)', () => {
|
||||
assert.equal(segKey([1.000001, 1], [1.000002, 2]), segKey([1.000002, 1], [1.000001, 2]));
|
||||
assert.equal(segKey([0, 0], [1, 1], 3), segKey([1, 1], [0, 0], 3));
|
||||
// разные стены — разные ключи
|
||||
assert.notEqual(segKey([0, 0], [1, 1]), segKey([0, 0], [2, 2]));
|
||||
});
|
||||
|
||||
@@ -31,7 +31,8 @@ async def test_upload_ok(hass: HomeAssistant, hass_client: ClientSessionGenerato
|
||||
resp = await client.post("/api/houseplan/upload", data=fd)
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["ok"] and body["url"].startswith("/houseplan_files/files/m1/manual.pdf?v=")
|
||||
# audit B1: uploads now return the AUTHENTICATED content URL
|
||||
assert body["ok"] and body["url"].startswith("/api/houseplan/content/files/m1/manual.pdf?v=")
|
||||
|
||||
|
||||
async def test_upload_bad_ext(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None:
|
||||
@@ -57,4 +58,4 @@ async def test_upload_traversal_sanitized(hass: HomeAssistant, hass_client: Clie
|
||||
# no path segment may be exactly ".." (dots inside a name are harmless)
|
||||
path = body["url"].split("?", 1)[0]
|
||||
assert all(seg != ".." for seg in path.split("/"))
|
||||
assert path.startswith("/houseplan_files/files/")
|
||||
assert path.startswith("/api/houseplan/content/files/")
|
||||
|
||||
@@ -99,4 +99,81 @@ async def test_plan_set_validates(hass: HomeAssistant, hass_ws_client: WebSocket
|
||||
{"type": "houseplan/plan/set", "space_id": "s1", "ext": "png", "data": "aGVsbG8="}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"] and resp["result"]["url"].startswith("/houseplan_files/plans/s1.png?v=")
|
||||
assert resp["success"] and resp["result"]["url"].startswith("/api/houseplan/content/plans/_/s1.png?v=")
|
||||
|
||||
|
||||
async def test_admin_check_fails_closed(hass, hass_ws_client):
|
||||
"""audit B2/T4: with no config entry the policy is unknown — deny writes.
|
||||
|
||||
This used to allow them: plan uploads slipped through during a reload.
|
||||
"""
|
||||
from custom_components.houseplan import websocket_api as wsapi
|
||||
|
||||
class _User:
|
||||
is_admin = False
|
||||
|
||||
class _Conn:
|
||||
user = _User()
|
||||
|
||||
# no entry at all → non-admin must be refused
|
||||
assert wsapi._check_write(hass, _Conn()) is False
|
||||
|
||||
class _Admin:
|
||||
is_admin = True
|
||||
|
||||
class _AdminConn:
|
||||
user = _Admin()
|
||||
|
||||
assert wsapi._check_write(hass, _AdminConn()) is True
|
||||
|
||||
|
||||
async def test_files_migrate_copies_and_reports_mapping(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""review CR-2/CR-3: migrate COPIES, never overwrites, and reports the mapping."""
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import FILES_DIR
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
base = hass.config.path(FILES_DIR)
|
||||
src = os.path.join(base, "old1")
|
||||
dst = os.path.join(base, "new1")
|
||||
|
||||
def _prepare() -> None:
|
||||
os.makedirs(src, exist_ok=True)
|
||||
os.makedirs(dst, exist_ok=True)
|
||||
with open(os.path.join(src, "m.pdf"), "wb") as fh:
|
||||
fh.write(b"SOURCE")
|
||||
# a DIFFERENT file already owns the name in the destination
|
||||
with open(os.path.join(dst, "m.pdf"), "wb") as fh:
|
||||
fh.write(b"OTHER")
|
||||
|
||||
await hass.async_add_executor_job(_prepare)
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/files/migrate", "from_id": "old1", "to_id": "new1"}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"], resp
|
||||
mapping = resp["result"]["mapping"]
|
||||
assert mapping["m.pdf"] != "m.pdf" # renamed instead of overwriting
|
||||
|
||||
def _read_all() -> tuple[bool, bytes, bytes]:
|
||||
with open(os.path.join(dst, "m.pdf"), "rb") as fh:
|
||||
other = fh.read()
|
||||
with open(os.path.join(dst, mapping["m.pdf"]), "rb") as fh:
|
||||
copied = fh.read()
|
||||
return os.path.isfile(os.path.join(src, "m.pdf")), other, copied
|
||||
|
||||
src_kept, other, copied = await hass.async_add_executor_job(_read_all)
|
||||
assert src_kept, "migrate must COPY, not move (review CR-2)"
|
||||
assert other == b"OTHER" and copied == b"SOURCE"
|
||||
|
||||
# cleanup runs only after the config is safely committed
|
||||
await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "old1"})
|
||||
resp2 = await client.receive_json()
|
||||
assert resp2["success"] and resp2["result"]["removed"] is True
|
||||
assert not await hass.async_add_executor_job(lambda: os.path.isdir(src))
|
||||
|
||||
@@ -125,3 +125,18 @@ def test_space_temp_bounds():
|
||||
"settings": {"fill_mode": "temp", "temp_min": 19.5, "temp_max": "24"},
|
||||
}
|
||||
v.SPACE_SCHEMA(ok)
|
||||
|
||||
|
||||
def test_finite_coordinates_rejected():
|
||||
"""audit B5: NaN/Infinity coordinates must not reach storage."""
|
||||
for bad in ("NaN", "Infinity", "-Infinity", float("nan"), float("inf")):
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.LAYOUT_SCHEMA({"dev1": {"x": bad, "y": 0.5}})
|
||||
assert v.LAYOUT_SCHEMA({"dev1": {"x": 0.5, "y": 0.25}})
|
||||
|
||||
|
||||
def test_collection_caps():
|
||||
"""audit B5: unbounded collections are capped."""
|
||||
big = {f"d{i}": {"x": 0.1, "y": 0.1} for i in range(v.MAX_LAYOUT + 1)}
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.LAYOUT_SCHEMA(big)
|
||||
|
||||
Reference in New Issue
Block a user