mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50ba443492 | ||
|
|
aa01eaef01 | ||
|
|
93b60c5b8e | ||
|
|
eb17396006 | ||
|
|
0af50a74ae | ||
|
|
232c4807fd | ||
|
|
285d569102 | ||
|
|
22b588e116 | ||
|
|
1da1aba625 | ||
|
|
de53d530fa | ||
|
|
ade8daab16 | ||
|
|
05a2a838d6 | ||
|
|
f4ad843619 | ||
|
|
a7d58f0552 | ||
|
|
fd72330549 | ||
|
|
2c947f4f7a | ||
|
|
693601a8e0 | ||
|
|
c7fa9542ba | ||
|
|
478d2042b2 | ||
|
|
47ab60cddd | ||
|
|
79142d9334 | ||
|
|
bb4d4e1f6e | ||
|
|
b6675dc3a4 | ||
|
|
53e1c7163d | ||
|
|
ca66791440 | ||
|
|
db19753bbf | ||
|
|
9be44dab1e | ||
|
|
ba88782ce1 | ||
|
|
a20b73621f | ||
|
|
b70153769a | ||
|
|
5e5c06f126 | ||
|
|
c4a80bcb9f | ||
|
|
9ccc3831d1 | ||
|
|
33a960031e | ||
|
|
3a6a819dec | ||
|
|
c024c6d75a | ||
|
|
a8bb145ffb | ||
|
|
1e0295a370 | ||
|
|
c74ce39eb3 | ||
|
|
ee0ee9a1d3 | ||
|
|
a8d40e4d99 | ||
|
|
63a8274623 | ||
|
|
1a75ee1ddf | ||
|
|
e81e841ed6 | ||
|
|
3e23ff58c3 | ||
|
|
21786ed5a1 | ||
|
|
6414873fb0 | ||
|
|
941d2709fd | ||
|
|
9bcfebe8f4 | ||
|
|
8b439ea0f3 | ||
|
|
b7d5cb8a8f | ||
|
|
84f8bcf0e6 | ||
|
|
10927ffaed | ||
|
|
6ff79106b8 | ||
|
|
f49ac613b5 | ||
|
|
ec58b0602a | ||
|
|
65070c0520 | ||
|
|
fa32afa9f3 | ||
|
|
adaed9be7c | ||
|
|
b508c20ad3 | ||
|
|
ca07579b63 | ||
|
|
29dae45e2a | ||
|
|
88008961e3 | ||
|
|
6d16f69f38 | ||
|
|
92ea8fa03c | ||
|
|
6743b6efc4 | ||
|
|
c9775141c9 | ||
|
|
9c224e7606 | ||
|
|
93d97e1a83 | ||
|
|
fad2e87ab5 | ||
|
|
909bb6fbc7 | ||
|
|
4e3d8f1d53 | ||
|
|
a1f7fb4161 | ||
|
|
257a71123a | ||
|
|
69c5a4c41d | ||
|
|
77327c07d4 | ||
|
|
18b8589438 | ||
|
|
9870b2fb98 | ||
|
|
7de7cfb8c4 | ||
|
|
18ac9b459f | ||
|
|
4e355dcbd2 | ||
|
|
f9b612a389 | ||
|
|
d27864e90e | ||
|
|
f3bc3278e5 | ||
|
|
75524d9d85 | ||
|
|
089c5ef462 | ||
|
|
8dbc7db0b2 | ||
|
|
23daa28cf4 | ||
|
|
d063453670 | ||
|
|
f83577afa7 | ||
|
|
eddc8b41db | ||
|
|
1eabfeeee8 | ||
|
|
11186371a9 | ||
|
|
692bb91e57 | ||
|
|
893ccff94f | ||
|
|
d2faa070b5 | ||
|
|
f5c56d2f8e | ||
|
|
0142d37bec | ||
|
|
40abbccbf0 | ||
|
|
831e694f83 | ||
|
|
b350893448 | ||
|
|
fc8b6f85a4 | ||
|
|
6fc60e3a50 | ||
|
|
dcc0b156af | ||
|
|
9d56708f2d | ||
|
|
6d8ec7b92a | ||
|
|
a9b999b3e0 | ||
|
|
f36d2cddad | ||
|
|
1770ca2960 | ||
|
|
764e023996 | ||
|
|
ce6a11f53f | ||
|
|
3e976562ff | ||
|
|
f56bceef27 | ||
|
|
96a01e1380 | ||
|
|
08a9cc7d27 | ||
|
|
1be79a1427 | ||
|
|
d7c20ff6a5 | ||
|
|
fa59767c69 | ||
|
|
60d6167ecd | ||
|
|
110fabd038 | ||
|
|
3456706ef6 | ||
|
|
1310c84a32 | ||
|
|
94563d2a0c | ||
|
|
5615afa33b | ||
|
|
4083e14247 | ||
|
|
518d72fb74 | ||
|
|
fc95a1f09b | ||
|
|
4e736d49a3 | ||
|
|
aa3c379540 | ||
|
|
fa15598e67 | ||
|
|
9eec856d50 | ||
|
|
0bb9282edd | ||
|
|
09f4dc4115 | ||
|
|
2551b4ea0e | ||
|
|
694e1e9a3b | ||
|
|
996a7442ec | ||
|
|
098a147f87 | ||
|
|
14f7c06bf4 | ||
|
|
9f36b39379 | ||
|
|
9da96abb05 | ||
|
|
df65d25348 | ||
|
|
8db6b2673f | ||
|
|
c9030af900 | ||
|
|
5392dadeaa | ||
|
|
aa53b33dd6 | ||
|
|
a8ce6020f4 | ||
|
|
9282c28830 | ||
|
|
8c5d5ba5c5 | ||
|
|
6c90e03427 | ||
|
|
9b180c5917 | ||
|
|
3084472c75 | ||
|
|
c00048611e | ||
|
|
f5e6c0318d | ||
|
|
e1e730560d | ||
|
|
94b298962a | ||
|
|
f7fe63776a | ||
|
|
01bc4f9711 | ||
|
|
85491d0fea | ||
|
|
d37a67c29f | ||
|
|
a66272c6f4 | ||
|
|
f4af2fe508 | ||
|
|
8e07e3c958 | ||
|
|
9868f1035f | ||
|
|
f2c9b07cc1 | ||
|
|
2c7a2f849d | ||
|
|
33e71ca96c | ||
|
|
7128ab504d | ||
|
|
f953a3c286 | ||
|
|
ef270d11b7 | ||
|
|
dc24390222 | ||
|
|
254354bf56 | ||
|
|
c9a60a110d | ||
|
|
75279308c1 | ||
|
|
b7ae3e7adf | ||
|
|
379fb68db2 | ||
|
|
96a70495d3 | ||
|
|
d3db9e30e6 | ||
|
|
2e731debd9 | ||
|
|
a49b5e6d2e | ||
|
|
4418312b0b | ||
|
|
3f719cc32a | ||
|
|
260615a63f | ||
|
|
e4e300adaa | ||
|
|
96d387ff1d | ||
|
|
8b531db3f5 |
@@ -5,6 +5,7 @@
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](LICENSE)
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
**Turn Home Assistant into a live, interactive map of your home.** Upload or draw
|
||||
@@ -16,23 +17,45 @@ right on your Lovelace dashboard.
|
||||
|
||||

|
||||
|
||||
> ### 🚀 Try it live — no install needed
|
||||
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — a real Home Assistant
|
||||
> with a ready-made plan. Log in as **`demo`** / **`demo`** and click anything:
|
||||
> toggle lights, open the editors, break things. The stand resets itself to a
|
||||
> pristine state every hour.
|
||||
|
||||
🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
|
||||
**Feature highlights**
|
||||
|
||||
- ♾️ **An infinite canvas** — there is no "plan size" and no edge to run
|
||||
past: draw and place devices anywhere, pan at any zoom, zoom out to see
|
||||
everything, and let one tap fit the whole plan back on screen.
|
||||
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
|
||||
virtual walls and a visual decor layer, all drawn with clicks; smart
|
||||
virtual walls and a visual decor layer, all drawn with clicks; room resize
|
||||
by dragging walls, with live lengths and areas as you drag; 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.
|
||||
- ☀️ **The sun on the plan** — set the compass and the backdrop lives with
|
||||
the day (white noon → golden hour → deep night), while windows on exterior
|
||||
walls cast real wedges of sunlight into the rooms; optional cloud cover
|
||||
from a weather entity.
|
||||
- 🪟 **Curtains and blinds open on a tap** — one action opens, closes or
|
||||
stops a cover, and the icon itself morphs between open and closed while a
|
||||
soft ring pulses as it travels.
|
||||
- 🌡 **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.
|
||||
- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck
|
||||
drives the plan in real time, pouring its path out from under itself;
|
||||
current and previous cleanup runs are recorded server-side. Calibration is
|
||||
one click (rooms matched by name) or a drag-and-stretch overlay. Works with
|
||||
Xiaomi Cloud Map Extractor, Tasshack dreame-vacuum and Valetudo.
|
||||
- 🔔 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.
|
||||
|
||||
@@ -75,6 +98,12 @@ Key advantages in short:
|
||||
- **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves.
|
||||
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
|
||||
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
|
||||
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
|
||||
a light is shining, a socket is powering, a fan is spinning, media is playing, a vacuum is
|
||||
cleaning, a radiator valve is actually heating (not merely enabled). Orange = open / unlocked.
|
||||
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
|
||||
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
|
||||
A translucent icon = unavailable. Dark = idle.
|
||||
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
|
||||
|
||||
---
|
||||
@@ -156,7 +185,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
|
||||
|
||||

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

|
||||
|
||||
@@ -199,7 +228,7 @@ walls** (it slides around corners too), and a **double click opens its propertie
|
||||
|
||||
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
|
||||
|
||||
By default only meaningful devices make it onto the plan — service records, bridges and duplicates are filtered out. If you need to see **absolutely all** devices of the area, enable the **👁 "Show all devices"** button in the header.
|
||||
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Show hidden"**: hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light.
|
||||
|
||||
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
|
||||
|
||||
@@ -281,7 +310,7 @@ Services → House Plan**.
|
||||
|
||||
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
|
||||
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden by curation (bridges, service records, duplicates) — enable the **👁 "Show all devices"** button in the header.
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden (the "Hide device from plan" checkbox — set automatically for bridges, scenes and other non-physical records) — open the device editor, press **"Show hidden"** and untick the box in its dialog.
|
||||
|
||||
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
|
||||
|
||||
|
||||
+34
-4
@@ -3,6 +3,7 @@
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
|
||||
@@ -14,23 +15,46 @@
|
||||
|
||||

|
||||
|
||||
> ### 🚀 Попробовать вживую — без установки
|
||||
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home
|
||||
> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё:
|
||||
> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам
|
||||
> возвращается в исходное состояние.
|
||||
|
||||
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
|
||||
**Главное**
|
||||
|
||||
- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который
|
||||
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
|
||||
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
|
||||
план обратно в экран.
|
||||
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
|
||||
виртуальные стены и декор-слой рисуются кликами; помощник выравнивания и
|
||||
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
|
||||
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
|
||||
линейка в реальных метрах.
|
||||
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
|
||||
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
|
||||
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
|
||||
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
|
||||
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
|
||||
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
|
||||
в комнаты настоящие клинья солнечного света; облачность — опционально, от
|
||||
weather-сущности.
|
||||
- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или
|
||||
останавливает штору, а сам значок морфится между открытым и закрытым
|
||||
видом и мягко пульсирует кольцом, пока штора едет.
|
||||
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
|
||||
температурная заливка по комфортным границам.
|
||||
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
|
||||
никогда случайным тапом.
|
||||
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
|
||||
этажами, автокарусель, свои размеры на каждом экране.
|
||||
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
|
||||
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
|
||||
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
|
||||
(по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi
|
||||
Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo.
|
||||
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
|
||||
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
|
||||
|
||||
@@ -74,6 +98,12 @@ House Plan показывает ваш умный дом так, как он в
|
||||
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
|
||||
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
|
||||
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
|
||||
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
|
||||
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
|
||||
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
|
||||
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
|
||||
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
|
||||
Полупрозрачный значок = недоступно. Тёмный = покой.
|
||||
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
|
||||
|
||||
---
|
||||
@@ -157,7 +187,7 @@ title: План дома
|
||||
|
||||

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

|
||||
|
||||
@@ -200,7 +230,7 @@ title: План дома
|
||||
|
||||
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
|
||||
|
||||
По умолчанию на план попадают только осмысленные устройства — служебные записи, мосты и дубликаты отфильтрованы. Если нужно видеть **вообще все** устройства зоны, включите в шапке кнопку **👁 «Показать все устройства»**.
|
||||
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) приходят с уже установленной галкой **«Скрыть устройство с плана»**. Дальше галка принадлежит вам — она есть в диалоге каждого устройства, включая виртуальные. Чтобы увидеть и вернуть скрытые, откройте редактор устройств и нажмите **«Показать скрытые»**: скрытые отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
|
||||
|
||||
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
|
||||
|
||||
@@ -283,7 +313,7 @@ title: План дома
|
||||
|
||||
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
|
||||
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто курированием (мосты, служебные, дубликаты) — включите в шапке кнопку **👁 «Показать все устройства»**.
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто (галка «Скрыть устройство с плана» — для мостов, сцен и прочих нефизических записей она ставится автоматически) — откройте редактор устройств, нажмите **«Показать скрытые»** и снимите галку в его диалоге.
|
||||
|
||||
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from . import websocket_api as hp_ws
|
||||
from .const import (
|
||||
@@ -18,6 +20,8 @@ from .const import (
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
from .repairs import async_check_plan_files
|
||||
from .store import HouseplanConfigEntry, create_data
|
||||
|
||||
@@ -46,6 +50,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
|
||||
entry.runtime_data = data
|
||||
|
||||
# server-side vacuum trails: the integration records the path itself
|
||||
from .trails import TrailRecorder
|
||||
recorder = TrailRecorder(hass, data)
|
||||
await recorder.async_setup()
|
||||
# setdefault: the CI harness sets entries up without async_setup, so
|
||||
# hass.data[DOMAIN] may not exist yet — a KeyError here failed EVERY
|
||||
# downstream WS test with unknown_error
|
||||
hass.data.setdefault(DOMAIN, {})["trail_recorder"] = recorder
|
||||
|
||||
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
|
||||
plans_path = Path(hass.config.path(PLANS_DIR))
|
||||
files_path = Path(hass.config.path(FILES_DIR))
|
||||
@@ -96,11 +109,103 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
module_url, module_url,
|
||||
)
|
||||
|
||||
# One-time move to the square canvas (v1.48.0). Coordinates used to be
|
||||
# normalised against a per-space aspect ratio; the canvas is now always
|
||||
# square and a plan is centred inside it. Nothing about the drawing changes
|
||||
# — the box is padded and the numbers re-expressed against it.
|
||||
# The two stores are written independently, and the lock is no transaction:
|
||||
# a crash between the writes used to leave the config in square coordinates
|
||||
# with the layout still in the old ones — permanently, because the config
|
||||
# write had already deleted the `aspect` fields the layout half needed
|
||||
# (HP-1490-01). So the intent is made durable FIRST, in the layout store,
|
||||
# and each half carries its own trigger with its own write: the config half
|
||||
# removes `aspect`, the layout half removes the saved intent. Whatever
|
||||
# half is missing after a crash, the next start finishes exactly it.
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
cfg = stored.get("config")
|
||||
lay_stored = await data.store.async_load() or {}
|
||||
layout = lay_stored.get("layout") or {}
|
||||
pending = {
|
||||
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
|
||||
}
|
||||
merged = {**pending, **pending_from_config(cfg)}
|
||||
if merged:
|
||||
lay_rev = int(lay_stored.get("rev", 0))
|
||||
if merged != pending: # 1. the durable intent, before anything moves
|
||||
await data.store.async_save(
|
||||
{"layout": layout, "rev": lay_rev, "geom_pending": merged}
|
||||
)
|
||||
rev = int(stored.get("rev", 0))
|
||||
if cfg and migrate_config(cfg): # 2. the config half
|
||||
rev += 1
|
||||
await data.config_store.async_save({"config": cfg, "rev": rev})
|
||||
migrate_layout(layout, merged) # 3. the layout half + intent cleared
|
||||
await data.store.async_save({"layout": layout, "rev": lay_rev + 1})
|
||||
_LOGGER.info(
|
||||
"House Plan: migrated %s space(s) to the square canvas", len(merged)
|
||||
)
|
||||
# only once both halves are durable — a client refetching on this
|
||||
# event must never see one migrated half and one old one
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
|
||||
# Scheduled collection of everything nobody ended up referencing.
|
||||
#
|
||||
# A commit collects what that commit superseded, which is the right rule for
|
||||
# a commit — but it only ever runs when somebody saves. Cancel a dialog
|
||||
# after the file has already uploaded, lose the connection after the upload
|
||||
# succeeded, or call the API directly, and the file is unreferenced with no
|
||||
# future write to notice it (HP-1461-01). The earlier version of this sweep
|
||||
# only removed streaming temporaries, which are a different, narrower case.
|
||||
#
|
||||
# Passing the CURRENT configuration as both sides means "nothing was
|
||||
# superseded": every referenced file is preserved and only unreferenced ones
|
||||
# past PLAN_ORPHAN_TTL_S go. It runs under the same lock as a config write,
|
||||
# so it cannot decide from a snapshot that a commit is about to replace.
|
||||
async def _sweep(_now=None) -> None:
|
||||
files_dir = Path(hass.config.path(FILES_DIR))
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
try:
|
||||
# `data` from the closure, NOT get_data(hass): during
|
||||
# async_setup_entry the entry is still SETUP_IN_PROGRESS, so
|
||||
# async_loaded_entries() does not list it and the lookup returned
|
||||
# None. The startup pass then silently degraded to removing
|
||||
# streaming temporaries only, and the real collection waited a full
|
||||
# day — restarting more often than that meant it never ran at all
|
||||
# (HP-1462-01). The callback is unregistered with the entry, so
|
||||
# closing over its runtime data matches the lifecycle exactly.
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
|
||||
def _collect() -> int:
|
||||
n = sweep_upload_temps(files_dir)
|
||||
# same config on both sides: nothing is superseded, so this
|
||||
# only ever collects what the shared rules call abandoned
|
||||
n += collect_attachments(files_dir, cfg, cfg)
|
||||
n += collect_plans(plans_dir, cfg, cfg)
|
||||
return n
|
||||
|
||||
n = await hass.async_add_executor_job(_collect)
|
||||
if n:
|
||||
_LOGGER.info("House Plan: removed %s unreferenced file(s)", n)
|
||||
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
|
||||
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
|
||||
|
||||
data.sweep = _sweep
|
||||
await _sweep()
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(hass, _sweep, timedelta(hours=24))
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
||||
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
|
||||
if rec:
|
||||
rec.teardown()
|
||||
"""Unload the entry.
|
||||
|
||||
WS commands and the HTTP view are global (async_setup) and stay registered —
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -17,14 +17,35 @@ CONTENT_URL = "/api/houseplan/content"
|
||||
# way to tell which paths were dropped (review R2-2).
|
||||
MAX_SIGN_PATHS = 200
|
||||
|
||||
# Nothing is ever deleted for being old (docs/SCOPE.md), so growth has to be
|
||||
# stopped at the door instead. These bound the whole store, not one request: by
|
||||
# default any authenticated user may upload, and a per-request cap of 8/50 MB
|
||||
# says nothing about how many requests there are (HP-1470-01).
|
||||
MAX_PLANS_BYTES = 256 * 1024 * 1024
|
||||
MAX_PLANS_FILES = 200
|
||||
# How many the picker asks for at once — newest first.
|
||||
MAX_PLANS_LISTED = 60
|
||||
MAX_FILES_BYTES = 1024 * 1024 * 1024
|
||||
MAX_FILES_COUNT = 1000
|
||||
# Refuse to write when the disk is nearly full: filling the config partition
|
||||
# breaks .storage, the recorder and backups, not just this card.
|
||||
MIN_FREE_BYTES = 512 * 1024 * 1024
|
||||
|
||||
# An uploaded plan that no accepted configuration references is collected only
|
||||
# once it is this old. Age is a race guard, not a policy: a plan uploaded
|
||||
# seconds ago may belong to another client's transaction that has not written
|
||||
# its configuration yet (review R3-1).
|
||||
PLAN_ORPHAN_TTL_S = 3600
|
||||
|
||||
# Kept for compatibility with anything reading it; the collectors no longer use
|
||||
# a long grace at all. Every attempt to age files out ended badly — first by
|
||||
# deleting detached plans, then by racing the save that was about to reference a
|
||||
# retried upload. What is left is deliberately simple: files go when the user's
|
||||
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.45.3"
|
||||
VERSION = "1.57.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,161 @@
|
||||
"""One-time migration to a square canvas — pure, so it can be tested alone.
|
||||
|
||||
Until v1.48.0 a space had an `aspect`, and coordinates were normalised against
|
||||
it: x by the width, y by the HEIGHT. Making every canvas square without touching
|
||||
the numbers would stretch every plan vertically.
|
||||
|
||||
Nothing about the drawing changes here. The canvas is padded to a square —
|
||||
top and bottom for a wide plan, left and right for a tall one — and the
|
||||
coordinates are re-expressed against that larger box. In render units it is a
|
||||
uniform scale plus an offset, so angles, room proportions and relative positions
|
||||
survive exactly. `cell_cm` follows, because the grid is tied to the width: for a
|
||||
tall plan the width grew, so the same wall would otherwise measure less.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def transform_for(aspect: float) -> tuple[float, float, float, float]:
|
||||
"""(dx, dy, kx, ky) that map old normalised coordinates onto the square.
|
||||
|
||||
x' = dx + x * kx, y' = dy + y * ky. Lengths along an axis scale by that
|
||||
axis's factor; both are the same uniform scale in RENDER units, which is
|
||||
why angles are preserved.
|
||||
"""
|
||||
a = float(aspect)
|
||||
if not a or a <= 0:
|
||||
a = 1.0
|
||||
k = min(1.0, a) # how much the old box shrinks inside the square
|
||||
kx = k # x was normalised by the width
|
||||
ky = k / a # y was normalised by the height (= width / aspect)
|
||||
return (1.0 - kx) / 2, (1.0 - ky) / 2, kx, ky
|
||||
|
||||
|
||||
def _pt(p: Any, dx: float, dy: float, kx: float, ky: float) -> Any:
|
||||
if isinstance(p, (list, tuple)) and len(p) >= 2:
|
||||
return [dx + float(p[0]) * kx, dy + float(p[1]) * ky]
|
||||
return p
|
||||
|
||||
|
||||
def migrate_space(space: dict[str, Any]) -> bool:
|
||||
"""Rewrite one space in place. Returns True when anything was changed."""
|
||||
if "aspect" not in space:
|
||||
return False
|
||||
try:
|
||||
aspect = float(space.get("aspect") or 1)
|
||||
except (TypeError, ValueError):
|
||||
aspect = 1.0
|
||||
dx, dy, kx, ky = transform_for(aspect)
|
||||
space.pop("aspect", None)
|
||||
|
||||
for room in space.get("rooms") or []:
|
||||
if room.get("x") is not None:
|
||||
room["x"] = dx + float(room["x"]) * kx
|
||||
if room.get("y") is not None:
|
||||
room["y"] = dy + float(room["y"]) * ky
|
||||
if room.get("w") is not None:
|
||||
room["w"] = float(room["w"]) * kx
|
||||
if room.get("h") is not None:
|
||||
room["h"] = float(room["h"]) * ky
|
||||
if room.get("poly"):
|
||||
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
|
||||
|
||||
for op in space.get("openings") or []:
|
||||
op["x"] = dx + float(op.get("x", 0)) * kx
|
||||
op["y"] = dy + float(op.get("y", 0)) * ky
|
||||
# a length is measured along the wall, and the render scale is uniform
|
||||
if op.get("length") is not None:
|
||||
op["length"] = float(op["length"]) * kx
|
||||
|
||||
for shape in space.get("decor") or []:
|
||||
for a, b, fx, fy in (("x1", "y1", kx, ky), ("x2", "y2", kx, ky), ("x", "y", kx, ky)):
|
||||
if shape.get(a) is not None:
|
||||
shape[a] = dx + float(shape[a]) * fx
|
||||
if shape.get(b) is not None:
|
||||
shape[b] = dy + float(shape[b]) * fy
|
||||
if shape.get("w") is not None:
|
||||
shape["w"] = float(shape["w"]) * kx
|
||||
if shape.get("h") is not None:
|
||||
shape["h"] = float(shape["h"]) * ky
|
||||
|
||||
# The viewport becomes the whole square rather than the transformed old
|
||||
# rectangle. It is what the grid is drawn over and what "fit to screen"
|
||||
# fits, so keeping the old box would leave the new margins outside the
|
||||
# canvas — no dots, nothing to draw on — which is exactly the room this
|
||||
# change was meant to give.
|
||||
space["view_box"] = [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
# The grid pitch is a fraction of the WIDTH. A tall plan just got a wider
|
||||
# canvas, so a wall now covers fewer cells; without this every measurement
|
||||
# in the plan would silently shrink.
|
||||
if kx != 1:
|
||||
try:
|
||||
cell = float(space.get("cell_cm") or 5)
|
||||
except (TypeError, ValueError):
|
||||
cell = 5.0
|
||||
space["cell_cm"] = round(cell / kx, 4)
|
||||
|
||||
# The image keeps its own proportions and is centred; the space no longer
|
||||
# has any of its own.
|
||||
if space.get("plan_url") and not space.get("plan_aspect"):
|
||||
space["plan_aspect"] = round(aspect, 6)
|
||||
return True
|
||||
|
||||
|
||||
def pending_from_config(config: dict[str, Any] | None) -> dict[str, float]:
|
||||
"""{space_id: old aspect} for every space still carrying one.
|
||||
|
||||
This is the migration INTENT. The two stores are written independently and
|
||||
either write can fail, so the intent has to survive on its own: it is saved
|
||||
into the layout store BEFORE anything changes (HP-1490-01), and cleared by
|
||||
the same write that stores the migrated layout. A crash between the writes
|
||||
leaves the intent behind, and the next start finishes the missing half —
|
||||
each half is idempotent because its trigger (`aspect` in the config, the
|
||||
saved intent for the layout) travels with that half's own write.
|
||||
"""
|
||||
out: dict[str, float] = {}
|
||||
for space in (config or {}).get("spaces") or []:
|
||||
if "aspect" not in space:
|
||||
continue
|
||||
try:
|
||||
out[str(space.get("id"))] = float(space.get("aspect") or 1) or 1.0
|
||||
except (TypeError, ValueError):
|
||||
out[str(space.get("id"))] = 1.0
|
||||
return out
|
||||
|
||||
|
||||
def migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
|
||||
"""The config half: migrate every space still carrying an `aspect`.
|
||||
|
||||
`layout` is accepted for backward compatibility and migrated with the
|
||||
factors found in the config — callers that can crash between store writes
|
||||
should use `pending_from_config()` + `migrate_layout()` instead, so the
|
||||
layout half does not depend on state the config half just deleted.
|
||||
"""
|
||||
factors = pending_from_config(config)
|
||||
if not factors:
|
||||
return False
|
||||
for space in config.get("spaces") or []:
|
||||
migrate_space(space)
|
||||
if layout:
|
||||
migrate_layout(layout, factors)
|
||||
return True
|
||||
|
||||
|
||||
def migrate_layout(layout: dict[str, Any] | None, pending: dict[str, float]) -> bool:
|
||||
"""The layout half: marker and label positions of the spaces in `pending`."""
|
||||
changed = False
|
||||
for pos in (layout or {}).values():
|
||||
if not isinstance(pos, dict) or str(pos.get("s")) not in pending:
|
||||
continue
|
||||
dx, dy, kx, ky = transform_for(pending[str(pos.get("s"))])
|
||||
if pos.get("x") is not None:
|
||||
pos["x"] = dx + float(pos["x"]) * kx
|
||||
if pos.get("y") is not None:
|
||||
pos["y"] = dy + float(pos["y"]) * ky
|
||||
changed = True
|
||||
return changed
|
||||
@@ -6,6 +6,8 @@ breaks the connection on a large PDF) but via a plain multipart POST — like me
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
@@ -18,8 +20,12 @@ except ImportError: # older HA versions
|
||||
KEY_HASS = "hass" # type: ignore[assignment]
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT, PLANS_DIR,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -31,6 +37,8 @@ from .validation import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_CHUNK = 64 * 1024
|
||||
# batch disk writes: one executor job per megabyte instead of per chunk
|
||||
_FLUSH_AT = 1024 * 1024
|
||||
|
||||
_MIME = {
|
||||
".pdf": "application/pdf",
|
||||
@@ -73,17 +81,35 @@ class HouseplanContentView(HomeAssistantView):
|
||||
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:
|
||||
if not await hass.async_add_executor_job(path.is_file):
|
||||
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"},
|
||||
)
|
||||
suffix = path.suffix.lower()
|
||||
headers = {
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
"Content-Type": _MIME.get(suffix, "application/octet-stream"),
|
||||
}
|
||||
if suffix == ".svg":
|
||||
# An uploaded SVG is user content served from Home Assistant's own
|
||||
# origin. Inside the card it is referenced by <image>, where scripts
|
||||
# never run — but the same url opened as a top-level document is a
|
||||
# live document of this origin, and a <script> in it reaches the
|
||||
# session's localStorage and API (HP-1454-01, 2026-07-28: uploading
|
||||
# needs write access, which by default every authenticated user has,
|
||||
# and the signed url is easy to hand to an admin).
|
||||
#
|
||||
# `sandbox` with no allow-* tokens drops the document into an opaque
|
||||
# origin: no scripts, no same-origin access, no forms. The explicit
|
||||
# directives below are belt and braces for older engines. Only SVG
|
||||
# gets this — a CSP on a PDF response can break the browser's built-in
|
||||
# viewer, and a raster image cannot execute anything in the first place.
|
||||
headers["Content-Security-Policy"] = (
|
||||
"sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
|
||||
"base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:"
|
||||
)
|
||||
# FileResponse streams from disk: a 50 MB manual used to be read whole
|
||||
# into memory and copied into the response body, so a couple of parallel
|
||||
# downloads could push a small Home Assistant host into swap (HP-1454-06).
|
||||
return web.FileResponse(path, chunk_size=_CHUNK, headers=headers)
|
||||
|
||||
|
||||
class HouseplanUploadView(HomeAssistantView):
|
||||
@@ -98,55 +124,132 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
if not may_write(hass, request.get("hass_user")):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
|
||||
files_root = Path(hass.config.path(FILES_DIR))
|
||||
marker_id = "misc"
|
||||
filename: str | None = None
|
||||
blob: bytes | None = None
|
||||
too_large = False
|
||||
# Every temporary file this request creates, promoted or not. The outer
|
||||
# `finally` removes whatever is left: a dropped connection, a second
|
||||
# `file` part or a failure while promoting used to leave a `.upload-*`
|
||||
# behind for good, and the collector only ever walks marker folders, so
|
||||
# nothing would have picked it up (HP-1460-02).
|
||||
temps: list[Path] = []
|
||||
error: tuple[dict, int] | None = None
|
||||
|
||||
def _new_tmp() -> Path:
|
||||
files_root.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=TMP_PREFIX, dir=str(files_root))
|
||||
os.close(fd)
|
||||
return Path(name)
|
||||
|
||||
def _flush(target: Path, blocks: list[bytes]) -> None:
|
||||
with open(target, "ab") as fh:
|
||||
for block in blocks:
|
||||
fh.write(block)
|
||||
|
||||
def _cleanup(paths: list[Path]) -> None:
|
||||
for path in paths:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name == "marker_id":
|
||||
marker_id = sanitize_marker_id(await part.text())
|
||||
elif part.name == "file":
|
||||
filename = part.filename or "file"
|
||||
# read in chunks, aborting at the limit, instead of loading the whole file into memory
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_BYTES:
|
||||
too_large = True
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name == "marker_id":
|
||||
marker_id = sanitize_marker_id(await part.text())
|
||||
elif part.name == "file":
|
||||
if filename is not None:
|
||||
# one upload per request: a second part would strand
|
||||
# the first temporary file and make the response
|
||||
# ambiguous about which url was returned
|
||||
error = ({"error": "one_file_only"}, 400)
|
||||
break
|
||||
chunks.append(chunk)
|
||||
if too_large:
|
||||
break
|
||||
blob = b"".join(chunks)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
filename = part.filename or "file"
|
||||
if file_ext(filename) not in FILE_EXTENSIONS:
|
||||
error = ({"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, 400)
|
||||
break
|
||||
# Stream to a temporary file instead of collecting the
|
||||
# whole upload in memory and copying it again into one
|
||||
# buffer: a 50 MB manual used to cost ~100 MB of RSS
|
||||
# mid-request (HP-1454-06). Blocks are batched so this
|
||||
# is one executor job per megabyte, not per 64 KB.
|
||||
tmp = await hass.async_add_executor_job(_new_tmp)
|
||||
temps.append(tmp)
|
||||
size = 0
|
||||
pending: list[bytes] = []
|
||||
buffered = 0
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_BYTES:
|
||||
error = (
|
||||
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024},
|
||||
413,
|
||||
)
|
||||
break
|
||||
pending.append(chunk)
|
||||
buffered += len(chunk)
|
||||
if buffered >= _FLUSH_AT:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
pending, buffered = [], 0
|
||||
if error:
|
||||
break
|
||||
if pending:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||
error = ({"error": "bad_request"}, 400)
|
||||
|
||||
if too_large:
|
||||
if error:
|
||||
return web.json_response(error[0], status=error[1])
|
||||
if not temps or not filename:
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
|
||||
tmp_path = temps[0]
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
check_quota, files_root, tmp_path.stat().st_size,
|
||||
MAX_FILES_BYTES, MAX_FILES_COUNT,
|
||||
)
|
||||
except QuotaError as err:
|
||||
_LOGGER.warning("House Plan upload refused: %s", err.detail)
|
||||
return web.json_response({"error": err.reason, "detail": err.detail}, status=507)
|
||||
except OSError:
|
||||
pass
|
||||
target_dir = files_root / marker_id
|
||||
safe_name = filename
|
||||
|
||||
def _promote() -> str:
|
||||
"""Claim a free name, then move the finished upload onto it.
|
||||
|
||||
Never overwrite an existing attachment: its bytes may be
|
||||
referenced by the stored configuration, and this upload is not
|
||||
part of that transaction — a cancelled dialog or a rejected save
|
||||
would leave the old url serving the new content (HP-1454-02).
|
||||
The name is reserved atomically, so two uploads racing on the
|
||||
same filename cannot agree on it (HP-1460-01).
|
||||
"""
|
||||
name = reserve_filename(target_dir, safe_name)
|
||||
try:
|
||||
os.replace(tmp_path, target_dir / name)
|
||||
except OSError:
|
||||
(target_dir / name).unlink(missing_ok=True)
|
||||
raise
|
||||
return name
|
||||
|
||||
try:
|
||||
name = await hass.async_add_executor_job(_promote)
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan upload: could not store the file: %s", err)
|
||||
return web.json_response({"error": "io_error"}, status=500)
|
||||
temps.remove(tmp_path) # it is the attachment now, not a temporary
|
||||
return web.json_response(
|
||||
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
|
||||
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{name}", "name": filename}
|
||||
)
|
||||
if blob is None or not filename:
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
ext = file_ext(filename)
|
||||
if ext not in FILE_EXTENSIONS:
|
||||
return web.json_response(
|
||||
{"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400
|
||||
)
|
||||
|
||||
safe_name = sanitize_filename(filename)
|
||||
target_dir = Path(hass.config.path(FILES_DIR)) / marker_id
|
||||
path = target_dir / safe_name
|
||||
|
||||
def _write() -> int:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(blob)
|
||||
return int(path.stat().st_mtime)
|
||||
|
||||
mtime = await hass.async_add_executor_job(_write)
|
||||
return web.json_response(
|
||||
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{safe_name}?v={mtime}", "name": filename}
|
||||
)
|
||||
finally:
|
||||
# BaseException too: cancelling the request task raises
|
||||
# asyncio.CancelledError, which an `except Exception` never saw —
|
||||
# an aborted large upload leaked its temporary file every time
|
||||
if temps:
|
||||
await hass.async_add_executor_job(_cleanup, list(temps))
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.45.3"
|
||||
"version": "1.57.0"
|
||||
}
|
||||
|
||||
@@ -1,22 +1,243 @@
|
||||
"""Plan-file collection — pure, so it is unit-testable without Home Assistant.
|
||||
"""Blob lifecycle — pure, so it is unit-testable without Home Assistant.
|
||||
|
||||
The file system is not part of the configuration store's transaction, so who
|
||||
may delete a plan file, and when, is a correctness question rather than a
|
||||
housekeeping one. It lives here, apart from the WebSocket plumbing, precisely
|
||||
because it is the part that has to be reasoned about and tested.
|
||||
may write or delete a plan or an attachment, and when, is a correctness
|
||||
question rather than housekeeping. It lives here, apart from the WebSocket and
|
||||
HTTP plumbing, precisely because it is the part that has to be reasoned about
|
||||
and tested.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .const import PLAN_ORPHAN_TTL_S
|
||||
from .validation import PLAN_EXTENSIONS
|
||||
from .const import MIN_FREE_BYTES, PLAN_ORPHAN_TTL_S
|
||||
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Streaming uploads land here first. The prefix is a dot so the name can never
|
||||
# collide with an attachment (sanitize_filename strips leading dots) and is easy
|
||||
# to sweep.
|
||||
TMP_PREFIX = ".upload-"
|
||||
|
||||
|
||||
def reserve_filename(directory: Path, name: str) -> str:
|
||||
"""Atomically claim a free name inside `directory` and return it.
|
||||
|
||||
Creates the file, empty, with `O_CREAT | O_EXCL`, so the name is *taken* the
|
||||
moment it is chosen. The previous version asked `exists()` and returned a
|
||||
string; two uploads racing between the check and the write agreed on the
|
||||
same name and one silently overwrote the other, both reporting success
|
||||
(HP-1460-01). The caller writes the real bytes over the placeholder — it
|
||||
owns the name by then — and must remove it if it never gets that far.
|
||||
|
||||
The result is guaranteed to satisfy `sanitize_filename(result) == result`:
|
||||
the content view sanitises the name in the request too, so a name it would
|
||||
shorten or rewrite is a file that is written and then never served.
|
||||
"""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
# Split the extension off the RAW name: sanitize_filename() truncates to
|
||||
# MAX_FILENAME, so sanitising first would cut ".pdf" off a long name and the
|
||||
# attachment would be stored — and served — without its type.
|
||||
base = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
stem, dot, suffix = base.rpartition(".")
|
||||
if not dot:
|
||||
stem, suffix = base, ""
|
||||
stem = sanitize_filename(stem)
|
||||
ext = f".{sanitize_filename(suffix)[:16]}" if suffix else ""
|
||||
i = 1
|
||||
while True:
|
||||
tag = "" if i == 1 else f"-{i}"
|
||||
# budget the stem so the WHOLE name fits, including the collision tag —
|
||||
# appending "-2" to an already maximal name produced a url the view
|
||||
# truncated back to something else, i.e. a permanent 404
|
||||
room = MAX_FILENAME - len(ext) - len(tag)
|
||||
candidate = (stem[:room] if room > 0 else "f") + tag + ext
|
||||
candidate = sanitize_filename(candidate)
|
||||
if candidate.startswith("."): # a name that is only an extension
|
||||
candidate = "file" + candidate
|
||||
try:
|
||||
fd = os.open(directory / candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
except FileExistsError:
|
||||
i += 1
|
||||
if i > 10000: # pathological directory; do not spin forever
|
||||
raise
|
||||
continue
|
||||
os.close(fd)
|
||||
return candidate
|
||||
|
||||
|
||||
def attachment_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
""""<marker>/<file>" for every attachment a configuration references."""
|
||||
out: set[str] = set()
|
||||
for m in (cfg or {}).get("markers") or []:
|
||||
for pdf in m.get("pdfs") or []:
|
||||
url = pdf.get("url") if isinstance(pdf, dict) else None
|
||||
if not isinstance(url, str) or "/files/" not in url:
|
||||
continue
|
||||
rel = url.split("?", 1)[0].split("/files/", 1)[1]
|
||||
if rel.count("/") == 1:
|
||||
out.add(rel)
|
||||
return out
|
||||
|
||||
|
||||
def sweep_upload_temps(files_dir: Path, now: float | None = None) -> int:
|
||||
"""Remove abandoned streaming temporaries (HP-1460-02).
|
||||
|
||||
The request itself deletes its own, but a hard kill — a restart mid-upload,
|
||||
an OOM — leaves one behind, and the attachment collector only walks marker
|
||||
folders, so it would never be seen. Age-gated for the same reason as the
|
||||
rest: a fresh one belongs to a request still in flight.
|
||||
"""
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
items = [p for p in files_dir.iterdir() if p.is_file()] if files_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
return 0
|
||||
for item in items:
|
||||
if not item.name.startswith(TMP_PREFIX):
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= cutoff:
|
||||
continue
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
return removed
|
||||
|
||||
|
||||
def collect_attachments(
|
||||
files_dir: Path,
|
||||
old_cfg: dict[str, Any] | None,
|
||||
new_cfg: dict[str, Any],
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
|
||||
|
||||
A file the old revision referenced and the new one does not, whose marker
|
||||
still exists, was removed on purpose — the dialog has a trash button and
|
||||
promises nothing. It goes. Everything else is kept, except a staging folder
|
||||
(`up_*`), which by construction only ever holds an upload from a dialog that
|
||||
was never saved: those go after PLAN_ORPHAN_TTL_S. Never raises: it runs
|
||||
behind a durable write.
|
||||
"""
|
||||
new_refs = attachment_refs(new_cfg)
|
||||
old_refs = attachment_refs(old_cfg)
|
||||
# Removing an attachment from a device that still exists is the user saying
|
||||
# "drop this one" — a trash button, no promise that anything is kept. A
|
||||
# device that is GONE is a different transition, and its files follow the
|
||||
# same rule as a deleted space's plan: kept.
|
||||
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
|
||||
# Same distinction as for plans. A staging folder (`up_*`) is different: it
|
||||
# only ever holds an upload from a dialog that was never saved, so the short
|
||||
# rule is exactly right there even on the timer.
|
||||
now_s = time.time() if now is None else now
|
||||
staging_cutoff = now_s - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
return 0
|
||||
removed += sweep_upload_temps(files_dir, now)
|
||||
for folder in folders:
|
||||
# A staging folder only ever holds an upload from a dialog that was never
|
||||
# saved — unambiguous, so an hour is right, and no device owns it.
|
||||
staging = folder.name.startswith("up_")
|
||||
try:
|
||||
items = sorted(p for p in folder.iterdir() if p.is_file())
|
||||
except OSError:
|
||||
continue
|
||||
for item in items:
|
||||
rel = f"{folder.name}/{item.name}"
|
||||
if rel in new_refs:
|
||||
continue
|
||||
dropped = rel in old_refs and folder.name in live_markers
|
||||
if not dropped:
|
||||
if not staging:
|
||||
# Same rule as for plans: not asked for, so kept. A file in
|
||||
# a device's folder that the device does not list is an
|
||||
# upload whose save was rejected — and ageing those out
|
||||
# raced the retry that was about to reference them.
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= staging_cutoff:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove the attachment %s: %s", item, err)
|
||||
try:
|
||||
next(folder.iterdir())
|
||||
except StopIteration:
|
||||
try:
|
||||
folder.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
class QuotaError(Exception):
|
||||
"""A store limit would be exceeded. Carries what to tell the user."""
|
||||
|
||||
def __init__(self, reason: str, detail: str) -> None:
|
||||
super().__init__(detail)
|
||||
self.reason = reason
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def dir_usage(path: Path) -> tuple[int, int]:
|
||||
"""(bytes, files) below `path`, ignoring what we cannot read."""
|
||||
total = count = 0
|
||||
if not path.is_dir():
|
||||
return 0, 0
|
||||
for item in path.rglob("*"):
|
||||
try:
|
||||
if item.is_file():
|
||||
total += item.stat().st_size
|
||||
count += 1
|
||||
except OSError:
|
||||
continue
|
||||
return total, count
|
||||
|
||||
|
||||
def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> None:
|
||||
"""Raise QuotaError unless `incoming` more bytes fit.
|
||||
|
||||
Deliberately not an age rule. Files are never removed for getting old — that
|
||||
cost real plans twice — so the limit sits where a decision is being made
|
||||
anyway: at the moment somebody asks to store something new.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
used, count = dir_usage(path)
|
||||
if count + 1 > max_files:
|
||||
raise QuotaError("too_many_files", f"{count} files already stored, the limit is {max_files}")
|
||||
if used + incoming > max_bytes:
|
||||
raise QuotaError(
|
||||
"quota_exceeded",
|
||||
f"{(used + incoming) // 1024 // 1024} MB would be stored, the limit is "
|
||||
f"{max_bytes // 1024 // 1024} MB",
|
||||
)
|
||||
try:
|
||||
free = shutil.disk_usage(str(path if path.is_dir() else path.parent)).free
|
||||
except OSError:
|
||||
return
|
||||
if free - incoming < MIN_FREE_BYTES:
|
||||
raise QuotaError("low_disk_space", f"only {free // 1024 // 1024} MB free on the disk")
|
||||
|
||||
|
||||
def plan_basename(url: Any) -> str:
|
||||
"""File name a stored plan_url points at ('' when there is none)."""
|
||||
@@ -35,6 +256,14 @@ def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
return out
|
||||
|
||||
|
||||
def plan_by_space(cfg: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""space id -> the plan file it references ('' when it has none)."""
|
||||
return {
|
||||
str(sp.get("id")): plan_basename(sp.get("plan_url"))
|
||||
for sp in (cfg or {}).get("spaces") or []
|
||||
}
|
||||
|
||||
|
||||
def is_plan_file(name: str) -> bool:
|
||||
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
|
||||
parts = name.split(".")
|
||||
@@ -61,7 +290,7 @@ def collect_plans(
|
||||
* a file the OLD configuration referenced and the new one does not was
|
||||
authoritative and has been superseded — remove it;
|
||||
* any other unreferenced plan file is a rejected or abandoned upload, and
|
||||
is removed only once PLAN_ORPHAN_TTL_S has passed: a fresh one may
|
||||
is KEPT — see the rule above; only a staging folder ages out: a fresh one may
|
||||
belong to a transaction that has not committed yet.
|
||||
|
||||
Never raises: the configuration is already stored by the time this runs, so
|
||||
@@ -69,7 +298,29 @@ def collect_plans(
|
||||
"""
|
||||
new_refs = plan_refs(new_cfg)
|
||||
old_refs = plan_refs(old_cfg)
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
# A commit knows what it superseded. The timer only knows what nothing
|
||||
# points at *right now*, and for a plan that is a reversible state: the
|
||||
# editor detaches the image when a space switches to "draw" and says the
|
||||
# file stays on disk. So the scheduled pass keeps anything belonging to a
|
||||
# space that still exists, and waits a month for the rest.
|
||||
# A space with NO plan_url has had its image detached — reversible, and the
|
||||
# editor promises the file stays. A space that HAS one is different: any
|
||||
# other file of its own is a superseded or rejected upload, so the short
|
||||
# rule is right for those. Getting this distinction wrong (protecting
|
||||
# nothing) destroyed two detached plans on 2026-07-28.
|
||||
# The short rule fits exactly one case: a space that HAS a plan, where any
|
||||
# other file of its own can only be a superseded or rejected upload.
|
||||
old_by_space = plan_by_space(old_cfg)
|
||||
new_by_space = plan_by_space(new_cfg)
|
||||
# A file that left the configuration tells us nothing on its own: replacing a
|
||||
# plan, detaching one and deleting a space all look identical from
|
||||
# `old_refs - new_refs`. Only the first is a deletion the user asked for
|
||||
# (HP-1465-01 — the guards below were written and then never reached,
|
||||
# because the code decided "superseded" before asking why).
|
||||
replaced = {
|
||||
name for space, name in old_by_space.items()
|
||||
if new_by_space.get(space) and new_by_space[space] != name
|
||||
}
|
||||
removed = 0
|
||||
try:
|
||||
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
||||
@@ -82,12 +333,21 @@ def collect_plans(
|
||||
for item in items:
|
||||
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
||||
continue
|
||||
superseded = item.name in old_refs
|
||||
try:
|
||||
stale = item.stat().st_mtime < cutoff
|
||||
except OSError:
|
||||
stale = False
|
||||
if not superseded and not stale:
|
||||
if item.name not in replaced:
|
||||
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
|
||||
# not told to delete is kept, however long it sits there. Detaching
|
||||
# is one click to undo and the editor says the image stays; deleting
|
||||
# a space is deliberate but the image was imported and may be
|
||||
# nowhere else. The errors are not symmetrical — unnecessary
|
||||
# megabytes can be removed by hand, a deleted file cannot be
|
||||
# brought back.
|
||||
#
|
||||
# There is deliberately no age rule here. An earlier version aged
|
||||
# out "rejected uploads" — a file of a space that has a plan, which
|
||||
# was never the plan — and that raced a save: the sweep deleted the
|
||||
# upload from the failed attempt while a retry was committing a
|
||||
# reference to it. A rule that can delete a file somebody is about
|
||||
# to point at is not worth the disk it reclaims.
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
|
||||
@@ -51,8 +51,17 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
|
||||
translation_key="broken_plan",
|
||||
translation_placeholders={"space": space_id, "file": fname},
|
||||
)
|
||||
# clear stale issues for spaces that are fine again (or gone)
|
||||
for sp in spaces:
|
||||
sid = sp.get("id", "?")
|
||||
if sid not in broken:
|
||||
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
|
||||
# Clear stale issues. Iterating the CURRENT spaces could only ever clear
|
||||
# issues for spaces that still exist, so deleting or renaming a space with a
|
||||
# missing plan left its warning in Repairs forever, with nothing left to fix
|
||||
# it (HP-1454-09). Enumerate what we actually published instead.
|
||||
registry = ir.async_get(hass)
|
||||
stale = [
|
||||
issue_id
|
||||
for (domain, issue_id) in list(registry.issues)
|
||||
if domain == DOMAIN
|
||||
and issue_id.startswith("broken_plan_")
|
||||
and issue_id[len("broken_plan_") :] not in broken
|
||||
]
|
||||
for issue_id in stale:
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -42,6 +43,17 @@ class HouseplanData:
|
||||
# One lock for every load→modify→save cycle of both stores: prevents
|
||||
# lost updates from concurrent WS calls and makes the rev check atomic.
|
||||
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
# A separate, narrower lock for the check-quota→write-file pair of an
|
||||
# upload. Without it N parallel uploads all measure the store BEFORE any
|
||||
# of them writes, and all pass a quota only one of them fits under
|
||||
# (HP-1490-02). Separate from write_lock so a slow directory scan does not
|
||||
# stall config/layout commits.
|
||||
upload_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
# Collect files nothing references any more. Set during setup, which also
|
||||
# runs it once and schedules it daily. Exposed so it can be invoked
|
||||
# directly — a test that fakes a 24 h jump proves the timer fires, not that
|
||||
# the work happens, and those are different claims.
|
||||
sweep: Callable[[], Awaitable[None]] | None = None
|
||||
|
||||
|
||||
HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
||||
|
||||
Executable
+242
@@ -0,0 +1,242 @@
|
||||
"""Server-side vacuum trails.
|
||||
|
||||
The integration records the robot's path ITSELF by watching the source
|
||||
entity's state changes — no card involvement. This removes every client-side
|
||||
race (N open tabs would fight over writes), survives page reloads by
|
||||
construction, and keeps recording while no card is open at all. Stored: the
|
||||
current run and one previous run per marker (owner call 2026-07-31 — users
|
||||
want to see where the cleanup has already been).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
import logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TRAIL_CAP = 2000 # raw points per run before decimation
|
||||
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
|
||||
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
|
||||
MOVING_STATES = {"cleaning", "returning", "on"}
|
||||
|
||||
|
||||
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
|
||||
"""Map-id normalisation contract, shared with the frontend.
|
||||
|
||||
Mirrors src/vacuum.ts vacMapIdFromAttrs (source attrs, `??`-chain) plus the
|
||||
card's _vacMapId fallback to the vacuum entity's selected_map. The FIRST
|
||||
value that is not None wins — truthiness is wrong here: a zero-based
|
||||
`map_index: 0` is a valid first map and an empty string is still an id.
|
||||
The old `or`-chain dropped the zero, so the server stored trails under a
|
||||
key the renderer never looked up (HP-1540-02).
|
||||
"""
|
||||
for v in (
|
||||
src_attrs.get("map_name"),
|
||||
src_attrs.get("current_map"),
|
||||
src_attrs.get("map_index"),
|
||||
src_attrs.get("selected_map"),
|
||||
vac_attrs.get("selected_map"),
|
||||
):
|
||||
if v is not None:
|
||||
return str(v)
|
||||
return "default"
|
||||
|
||||
|
||||
class TrailBook:
|
||||
"""Pure run bookkeeping: {marker: {current: run, previous: run}}.
|
||||
|
||||
A run is {"map_id", "started", "ended", "points": [[x, y], …]} in RAW
|
||||
robot coordinates — recalibration never invalidates a stored trail.
|
||||
"""
|
||||
|
||||
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
||||
self.data: dict[str, Any] = data if isinstance(data, dict) else {}
|
||||
|
||||
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
|
||||
rec = self.data.setdefault(marker, {})
|
||||
cur = rec.get("current")
|
||||
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
|
||||
# a new run begins: the old one becomes "previous" (and the one
|
||||
# before it is forgotten — we keep exactly two, per the owner)
|
||||
if cur:
|
||||
rec["previous"] = cur
|
||||
cur = {"map_id": map_id, "started": now, "ended": None, "points": []}
|
||||
rec["current"] = cur
|
||||
pts: list[list[float]] = cur["points"]
|
||||
if pts and pts[-1][0] == x and pts[-1][1] == y:
|
||||
return False
|
||||
pts.append([x, y])
|
||||
if len(pts) > TRAIL_CAP:
|
||||
# decimate by two but never lose the freshest point
|
||||
half = pts[0::2]
|
||||
if half[-1] != pts[-1]:
|
||||
half.append(pts[-1])
|
||||
cur["points"] = half
|
||||
return True
|
||||
|
||||
def end_run(self, marker: str, now: float) -> bool:
|
||||
cur = (self.data.get(marker) or {}).get("current")
|
||||
if cur and not cur.get("ended"):
|
||||
cur["ended"] = now
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TrailRecorder:
|
||||
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, rt: Any) -> None:
|
||||
self.hass = hass
|
||||
self.rt = rt
|
||||
self.store = Store(hass, 1, f"{DOMAIN}.trails")
|
||||
self.book = TrailBook()
|
||||
# HP-1540-03: one source may feed SEVERAL markers — the same robot
|
||||
# placed on two floors is the documented multi-floor case, and a plain
|
||||
# source → (marker, vacuum) dict silently kept only the last one
|
||||
self.pairs: dict[str, list[tuple[str, str]]] = {} # source → [(marker, vacuum), …]
|
||||
self._unsub_track = None
|
||||
self._unsub_save = None
|
||||
self._last_fire = 0.0
|
||||
# HP-1540-05: config/set fires refresh as a detached task; two of them
|
||||
# interleaving across the awaited load both subscribed and the loser's
|
||||
# unsub handle was overwritten — a leak until HA restart
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
self._closed = False
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
self.book = TrailBook(await self.store.async_load() or {})
|
||||
await self.async_refresh()
|
||||
|
||||
async def async_refresh(self) -> None:
|
||||
"""(Re)subscribe after any config change — markers may come and go.
|
||||
|
||||
Serialised (HP-1540-05): the lock makes unsubscribe-then-resubscribe
|
||||
atomic across the awaited config load, so overlapping refresh tasks can
|
||||
no longer both subscribe and strand one callback forever. The _closed
|
||||
check covers teardown() racing a refresh that is parked on its await.
|
||||
"""
|
||||
async with self._refresh_lock:
|
||||
stored = await self.rt.config_store.async_load() or {}
|
||||
if self._closed:
|
||||
return
|
||||
cfg = stored.get("config") or {}
|
||||
pairs: dict[str, list[tuple[str, str]]] = {}
|
||||
for m in cfg.get("markers") or []:
|
||||
v = m.get("vacuum") or {}
|
||||
src = v.get("source")
|
||||
if not src or v.get("live") is False:
|
||||
continue
|
||||
vac = self._vacuum_entity(m)
|
||||
if vac:
|
||||
# HP-1540-03: append, never overwrite — every floor's
|
||||
# marker records its own copy of the run
|
||||
pairs.setdefault(src, []).append((str(m.get("id")), vac))
|
||||
self.pairs = pairs
|
||||
if self._unsub_track:
|
||||
self._unsub_track()
|
||||
self._unsub_track = None
|
||||
# deduplicated: two markers of one robot share source AND vacuum
|
||||
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
|
||||
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
|
||||
if ents:
|
||||
self._unsub_track = async_track_state_change_event(
|
||||
self.hass, sorted(ents), self._on_state
|
||||
)
|
||||
# A run already in progress (HA restarted mid-cleanup, or the user
|
||||
# just finished calibrating) must start recording NOW, not at the
|
||||
# next state change — otherwise the first seconds of the path are
|
||||
# lost.
|
||||
for src in self.pairs:
|
||||
self._sample(src, time.time())
|
||||
|
||||
def teardown(self) -> None:
|
||||
# HP-1540-05: flag FIRST — a refresh parked on its awaited load must
|
||||
# not re-subscribe after this cleanup has already run
|
||||
self._closed = True
|
||||
if self._unsub_track:
|
||||
self._unsub_track()
|
||||
self._unsub_track = None
|
||||
if self._unsub_save:
|
||||
self._unsub_save()
|
||||
self._unsub_save = None
|
||||
|
||||
def _vacuum_entity(self, m: dict[str, Any]) -> str | None:
|
||||
b = str(m.get("binding") or "")
|
||||
if b.startswith("entity:vacuum."):
|
||||
return b[len("entity:"):]
|
||||
if b.startswith("device:"):
|
||||
reg = er.async_get(self.hass)
|
||||
for e in er.async_entries_for_device(reg, b[len("device:"):]):
|
||||
if e.entity_id.startswith("vacuum."):
|
||||
return e.entity_id
|
||||
return None
|
||||
|
||||
def _sample(self, src: str, now: float) -> bool:
|
||||
"""Record one point (or end the run) for EVERY marker fed by src.
|
||||
|
||||
HP-1540-03: the same source serves one marker per floor — all of them
|
||||
must receive the point, not just whichever survived the dict.
|
||||
"""
|
||||
changed = False
|
||||
for marker, vac in self.pairs.get(src) or ():
|
||||
st_vac = self.hass.states.get(vac)
|
||||
# "no state yet" is NOT "stopped": during HA boot the vacuum reads
|
||||
# unavailable and ending the run here would split one cleanup into
|
||||
# current+previous on every restart (observed live: 21 points
|
||||
# became previous, the same run restarted at 5)
|
||||
if not st_vac or st_vac.state in ("unavailable", "unknown"):
|
||||
continue
|
||||
if st_vac.state not in MOVING_STATES:
|
||||
changed |= self.book.end_run(marker, now)
|
||||
continue
|
||||
st_src = self.hass.states.get(src)
|
||||
attrs = st_src.attributes if st_src else {}
|
||||
raw = attrs.get("vacuum_position") or attrs.get("robot_position")
|
||||
# Server-side these attributes are often OBJECTS (Tasshack keeps a
|
||||
# Point dataclass in memory — it only becomes a dict when
|
||||
# serialised to the frontend). Caught live on the owner's X50: the
|
||||
# recorder saw every state change and rejected every single one.
|
||||
if isinstance(raw, dict):
|
||||
px, py = raw.get("x"), raw.get("y")
|
||||
else:
|
||||
px, py = getattr(raw, "x", None), getattr(raw, "y", None)
|
||||
try:
|
||||
x, y = float(px), float(py) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
map_id = resolve_map_id(attrs, st_vac.attributes)
|
||||
changed |= self.book.on_point(marker, map_id, x, y, now)
|
||||
return changed
|
||||
|
||||
@callback
|
||||
def _on_state(self, event: Any) -> None:
|
||||
eid = event.data.get("entity_id")
|
||||
now = time.time()
|
||||
changed = False
|
||||
for src, pair_list in self.pairs.items():
|
||||
if eid == src or any(eid == vac for _, vac in pair_list):
|
||||
changed |= self._sample(src, now)
|
||||
if changed:
|
||||
self._schedule_save()
|
||||
if now - self._last_fire >= FIRE_THROTTLE_S:
|
||||
self._last_fire = now
|
||||
self.hass.bus.async_fire("houseplan_trail_updated", {})
|
||||
|
||||
def _schedule_save(self) -> None:
|
||||
if self._unsub_save:
|
||||
return
|
||||
|
||||
async def _save(_now: Any) -> None:
|
||||
self._unsub_save = None
|
||||
await self.store.async_save(self.book.data)
|
||||
|
||||
self._unsub_save = async_call_later(self.hass, SAVE_DELAY_S, _save)
|
||||
@@ -16,6 +16,9 @@ MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
|
||||
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
# The name length the content view will accept back in a request. Anything a
|
||||
# generated name must fit inside, collision tag included (HP-1460-01).
|
||||
MAX_FILENAME = 120
|
||||
|
||||
# ---------- sanitizers ----------
|
||||
|
||||
@@ -33,7 +36,7 @@ def sanitize_marker_id(value: str) -> str:
|
||||
def sanitize_filename(value: str) -> str:
|
||||
"""Drop the path and leading dots, keep a safe file name."""
|
||||
raw = value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:120] or "file"
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:MAX_FILENAME] or "file"
|
||||
|
||||
|
||||
def file_ext(filename: str) -> str:
|
||||
@@ -66,14 +69,71 @@ MAX_MARKERS = 2000
|
||||
MAX_OPENINGS = 500
|
||||
MAX_DECOR = 1000
|
||||
MAX_LAYOUT = 5000
|
||||
# Inner limits (HP-1454-05). The outer collections were capped, the collections
|
||||
# INSIDE them were not: a 150 000-point polygon or a 100 000-entry known_devices
|
||||
# list passed validation, then made the card build gigantic SVG attributes and
|
||||
# walk them on every render. Any authenticated writer could store one, and with
|
||||
# `admin_only` off that is every user. These are product limits, not guesses: a
|
||||
# hand-drawn room does not need 500 vertices, and no home has 200 lights behind
|
||||
# one switch.
|
||||
MAX_POLY_POINTS = 500
|
||||
MAX_OPEN_TO = 50
|
||||
MAX_CONTROLS = 200
|
||||
MAX_PDFS = 50
|
||||
MAX_KNOWN_DEVICES = 20000
|
||||
MAX_TEXT = 500 # names, models, ids
|
||||
MAX_DESCRIPTION = 4000
|
||||
MAX_URL = 2000
|
||||
# Comfortably below the WebSocket frame limit (aiohttp's default is 4 MB): a
|
||||
# payload larger than the frame never reaches the handler at all — the socket
|
||||
# closes with 1009 and the user sees a dropped connection instead of an error
|
||||
# they can act on. For scale, a real three-floor home with ~200 devices stores
|
||||
# about 70 KB, so this is ~30x headroom.
|
||||
MAX_CONFIG_BYTES = 2 * 1024 * 1024
|
||||
|
||||
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
|
||||
_TEXT_OR_NONE = vol.Any(None, _TEXT)
|
||||
_URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
|
||||
# The canvas is UNBOUNDED (docs/CANVAS.md). Coordinates are still normalised —
|
||||
# 1.0 is still one canvas width — but there is no frame any more, so a plan may
|
||||
# legitimately live at 2.7 or -1.4. The range below is GARBAGE INSURANCE, not a
|
||||
# boundary: at the product's own scale (cell_cm=5, 240 cells across the unit
|
||||
# width) 5000 is about 60 km of plan, unreachable in a home, while a stored
|
||||
# 1e100 still cannot stretch every client's view until the plan is invisible
|
||||
# (HP-1500-03 / HP-1501-01). Widened from +/-4 on 2026-08-03.
|
||||
CANVAS_LIMIT = 5000.0
|
||||
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): _finite, vol.Required("y"): _finite},
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
POINT = vol.All([_finite], vol.Length(min=2, max=2))
|
||||
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
|
||||
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
|
||||
# corruption (HP-1501-01, the room-geometry twin of HP-1500-03).
|
||||
_GEOM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
# A SIZE is not a coordinate (HP-1502-01): SVG requires positive width/height,
|
||||
# and the clients divide by these. `view_box: [0,0,0,0]` passed the shared
|
||||
# validator and serialised into viewBox="0 0 0 0" — a blank plan on every
|
||||
# client. The floor is one thousandth of the canvas (1 render unit): far below
|
||||
# any real room, but keeps the maths finite. The CEILING follows the canvas
|
||||
# (docs/CANVAS.md) — a room on an unbounded plane may legitimately be wider
|
||||
# than the old unit square — while staying strictly positive.
|
||||
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT))
|
||||
|
||||
|
||||
def _view_box(value):
|
||||
"""[x, y, w, h]: the first two are coordinates, the last two are sizes."""
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 4:
|
||||
raise vol.Invalid("view_box must be [x, y, w, h]")
|
||||
return [_GEOM(value[0]), _GEOM(value[1]), _EXTENT(value[2]), _EXTENT(value[3])]
|
||||
|
||||
|
||||
POINT = vol.All([_GEOM], vol.Length(min=2, max=2))
|
||||
|
||||
|
||||
def _require_geometry(room: dict) -> dict:
|
||||
@@ -85,10 +145,10 @@ def _require_geometry(room: dict) -> dict:
|
||||
ROOM_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("name"): str,
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("open_to"): [str],
|
||||
vol.Required("id"): _TEXT,
|
||||
vol.Required("name"): _TEXT,
|
||||
vol.Optional("area"): _TEXT_OR_NONE,
|
||||
vol.Optional("open_to"): vol.All([_TEXT], vol.Length(max=MAX_OPEN_TO)),
|
||||
vol.Optional("settings"): vol.Any(
|
||||
None,
|
||||
vol.Schema(
|
||||
@@ -102,21 +162,39 @@ ROOM_SCHEMA = vol.All(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
),
|
||||
vol.Optional("x"): _finite,
|
||||
vol.Optional("y"): _finite,
|
||||
vol.Optional("w"): _finite,
|
||||
vol.Optional("h"): _finite,
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
|
||||
vol.Optional("x"): _GEOM,
|
||||
vol.Optional("y"): _GEOM,
|
||||
vol.Optional("w"): _EXTENT,
|
||||
vol.Optional("h"): _EXTENT,
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_require_geometry,
|
||||
)
|
||||
|
||||
def _north_deg(value):
|
||||
"""Compass (docs/SUN.md): strict integer degrees, 0..359.
|
||||
|
||||
Strict on purpose: Coerce(int) would take "90" and 1.5, and a bool is an
|
||||
int in Python — none of those is a compass reading a client stored.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise vol.Invalid("north_deg must be an integer in 0..359")
|
||||
if not 0 <= value <= 359:
|
||||
raise vol.Invalid("north_deg must be an integer in 0..359")
|
||||
return value
|
||||
|
||||
|
||||
_BG_MODE = vol.In(["static", "daynight"])
|
||||
|
||||
SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("show_borders"): bool,
|
||||
vol.Optional("show_names"): bool,
|
||||
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
# per-space background around the plan; absent = inherit the global one
|
||||
vol.Optional("bg_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", "glow"]),
|
||||
vol.Optional("temp_min"): vol.Coerce(float),
|
||||
@@ -127,6 +205,10 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
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)),
|
||||
# sun on the plan (docs/SUN.md): per-space overrides, absent = inherit
|
||||
vol.Optional("north_deg"): vol.Any(None, _north_deg),
|
||||
vol.Optional("bg_mode"): vol.Any(None, _BG_MODE),
|
||||
vol.Optional("sun_rays"): vol.Any(None, bool),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
@@ -136,7 +218,9 @@ _DECOR_COMMON = {
|
||||
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 lives on the same unbounded canvas as everything else (docs/CANVAS.md):
|
||||
# it used to be pinned to -1..2, i.e. "one canvas of slack around the square".
|
||||
_NORM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
DECOR_SCHEMA = vol.Any(
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
|
||||
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
|
||||
@@ -160,8 +244,16 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Required("title"): str,
|
||||
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
||||
vol.Optional("plan_url"): vol.Any(str, None),
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)),
|
||||
# The canvas is square since v1.48.0. What used to be the space's own
|
||||
# `aspect` is gone; the background image keeps its own proportions and
|
||||
# is centred, so only the IMAGE's ratio is stored. A stale tab may still
|
||||
# send the old field — it is dropped rather than trusted, because the
|
||||
# coordinates it comes with were normalised against a different box.
|
||||
vol.Remove("aspect"): object,
|
||||
vol.Optional("plan_aspect"): vol.Any(
|
||||
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
|
||||
),
|
||||
vol.Required("view_box"): _view_box,
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
vol.Optional("openings"): vol.All([
|
||||
@@ -169,10 +261,12 @@ SPACE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window"),
|
||||
vol.Required("x"): _finite,
|
||||
vol.Required("y"): _finite,
|
||||
vol.Required("angle"): _finite,
|
||||
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
|
||||
vol.Required("x"): _GEOM,
|
||||
vol.Required("y"): _GEOM,
|
||||
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
# a SIZE: strictly positive, capped by the canvas insurance
|
||||
# limit rather than by the old unit square (docs/CANVAS.md)
|
||||
vol.Required("length"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
|
||||
vol.Optional("contact"): vol.Any(str, None),
|
||||
vol.Optional("lock"): vol.Any(str, None),
|
||||
vol.Optional("invert"): bool,
|
||||
@@ -185,7 +279,10 @@ SPACE_SCHEMA = vol.Schema(
|
||||
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
|
||||
# independent existence. Still accepted so a stale browser tab cannot fail a save;
|
||||
# the card strips the field on every write.
|
||||
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
|
||||
# Accepted so a stale browser tab cannot fail a save, then DROPPED here
|
||||
# (HP-1454-05): relying on a modern client to strip an unbounded legacy
|
||||
# list is not a limit, it is a hope. `Remove` returns the key stripped.
|
||||
vol.Remove("segments"): object,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
@@ -197,15 +294,41 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("space"): vol.Any(str, None),
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("hidden"): bool,
|
||||
vol.Optional("name"): vol.Any(str, None),
|
||||
vol.Optional("icon"): vol.Any(str, None),
|
||||
vol.Optional("model"): vol.Any(str, None),
|
||||
vol.Optional("link"): vol.Any(str, None),
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
|
||||
vol.Optional("controls"): vol.Any([str], None),
|
||||
vol.Optional("name"): _TEXT_OR_NONE,
|
||||
vol.Optional("icon"): _TEXT_OR_NONE,
|
||||
vol.Optional("model"): _TEXT_OR_NONE,
|
||||
vol.Optional("link"): vol.Any(None, _URL),
|
||||
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "cover", None),
|
||||
# the 'run' target: only the runnable domains, nothing else is callable
|
||||
vol.Optional("tap_target"): vol.Any(
|
||||
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
|
||||
),
|
||||
vol.Optional("tap_confirm"): vol.Any(bool, None),
|
||||
# live robot vacuums (docs/VACUUM.md): everything optional so configs
|
||||
# from older versions stay valid untouched
|
||||
vol.Optional("vacuum"): vol.Any(
|
||||
None,
|
||||
vol.Schema({
|
||||
vol.Optional("live"): vol.Any(bool, None),
|
||||
vol.Optional("trail"): vol.Any(bool, None),
|
||||
vol.Optional("trail_mode"): vol.Any(
|
||||
None, vol.In(["never", "cleaning", "always"])
|
||||
),
|
||||
vol.Optional("room_highlight"): vol.Any(bool, None),
|
||||
vol.Optional("source"): vol.Any(str, None),
|
||||
# one 6-number affine per robot map; numbers must be finite
|
||||
vol.Optional("calibration"): vol.Schema(
|
||||
{str: vol.All([_finite], vol.Length(min=6, max=6))}
|
||||
),
|
||||
vol.Optional("segment_map"): vol.Schema({str: str}),
|
||||
}),
|
||||
),
|
||||
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
|
||||
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
|
||||
vol.Optional("is_light"): vol.Any(bool, None),
|
||||
# climate current_temperature: badge + room-average vote (off unless True)
|
||||
vol.Optional("use_climate_temp"): vol.Any(bool, None),
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
|
||||
# asserts every option the editor offers is accepted here (issue #3)
|
||||
@@ -214,9 +337,10 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
||||
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
|
||||
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
|
||||
vol.Optional("pdfs"): [
|
||||
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
|
||||
],
|
||||
vol.Optional("pdfs"): vol.All(
|
||||
[vol.Schema({vol.Required("name"): _TEXT, vol.Required("url"): _URL}, extra=vol.ALLOW_EXTRA)],
|
||||
vol.Length(max=MAX_PDFS),
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
@@ -227,8 +351,15 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
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],
|
||||
# background around the plan, all spaces (a space may override)
|
||||
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
# sun on the plan (docs/SUN.md): global defaults
|
||||
vol.Optional("north_deg"): _north_deg,
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
vol.Optional("sun_rays"): bool,
|
||||
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
|
||||
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -15,15 +16,21 @@ from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
CONTENT_URL, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
||||
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
|
||||
MAX_SIGN_PATHS,
|
||||
PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import collect_plans
|
||||
from .plans import (
|
||||
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
|
||||
plan_basename, plan_refs, reserve_filename,
|
||||
)
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -34,12 +41,16 @@ _LOGGER = logging.getLogger(__name__)
|
||||
def async_register(hass: HomeAssistant) -> None:
|
||||
"""Register the WS commands."""
|
||||
websocket_api.async_register_command(hass, ws_layout_get)
|
||||
websocket_api.async_register_command(hass, ws_trail_get)
|
||||
websocket_api.async_register_command(hass, ws_layout_set)
|
||||
websocket_api.async_register_command(hass, ws_geometry_repair)
|
||||
websocket_api.async_register_command(hass, ws_layout_update)
|
||||
websocket_api.async_register_command(hass, ws_layout_delete)
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_set)
|
||||
websocket_api.async_register_command(hass, ws_plans_list)
|
||||
websocket_api.async_register_command(hass, ws_plans_delete)
|
||||
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_content_sign)
|
||||
@@ -74,7 +85,9 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
if rt is None:
|
||||
return
|
||||
data = await rt.store.async_load() or {}
|
||||
connection.send_result(msg["id"], {"layout": data.get("layout", {})})
|
||||
connection.send_result(
|
||||
msg["id"], {"layout": data.get("layout", {}), "rev": int(data.get("rev", 0))}
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -107,8 +120,11 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
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})
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"layout": msg["layout"], "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -131,8 +147,102 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout", {})
|
||||
layout[msg["device_id"]] = msg["pos"]
|
||||
await rt.store.async_save({"layout": layout})
|
||||
connection.send_result(msg["id"], {"ok": True})
|
||||
# keep the revision: a point-wise write used to drop it, which made the
|
||||
# optimistic locking on layout/set meaningless — every drag reset the
|
||||
# counter to 0 (HP-1454-08)
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"layout": layout, "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/geometry/repair",
|
||||
vol.Required("space_id"): str,
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Optional("dry_run"): bool,
|
||||
vol.Optional("undo"): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Re-apply the square-canvas transform to ONE space's layout, explicitly.
|
||||
|
||||
For installations that hit the v1.48/v1.49 crash window: the config write
|
||||
of the migration landed, the layout write did not, and the trigger fields
|
||||
were already gone — markers and labels of that space are stranded in the
|
||||
old coordinates with nothing able to tell (HP-1500-01). Nothing can be
|
||||
detected reliably after the fact, and re-running a transform on a layout
|
||||
that is already correct would corrupt it, so this NEVER runs by itself:
|
||||
an administrator names the space and its old aspect, may preview with
|
||||
`dry_run`, and gets a one-deep backup written in the same store write —
|
||||
`undo` restores it.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may repair the layout")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
from .geometry_migration import migrate_layout
|
||||
|
||||
space_id = msg["space_id"]
|
||||
if not valid_space_id(space_id):
|
||||
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout") or {}
|
||||
current_rev = int(data.get("rev", 0))
|
||||
if msg.get("undo"):
|
||||
backup = data.get("repair_backup")
|
||||
if not isinstance(backup, dict) or backup.get("space") != space_id:
|
||||
connection.send_error(msg["id"], "no_backup", "No repair backup stored for this space")
|
||||
return
|
||||
restored = dict(layout)
|
||||
for key, pos in (backup.get("positions") or {}).items():
|
||||
restored[key] = pos
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({"layout": restored, "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev,
|
||||
"restored": len(backup.get("positions") or {})})
|
||||
return
|
||||
touched = {
|
||||
k: dict(v) for k, v in layout.items()
|
||||
if isinstance(v, dict) and str(v.get("s")) == space_id
|
||||
}
|
||||
if not touched:
|
||||
# A typo'd space id used to "succeed" with moved: 0 — and its
|
||||
# empty result REPLACED the one-deep backup, destroying the very
|
||||
# undo this endpoint promises (HP-1501-02). Nothing to move means
|
||||
# nothing to save: no write, no revision bump, the backup stays.
|
||||
connection.send_error(
|
||||
msg["id"], "nothing_to_repair",
|
||||
f"No stored positions belong to space '{space_id}'",
|
||||
)
|
||||
return
|
||||
preview = {k: dict(v) for k, v in touched.items()}
|
||||
migrate_layout(preview, {space_id: msg["aspect"]})
|
||||
if msg.get("dry_run"):
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True, "dry_run": True, "moved": len(preview),
|
||||
"before": touched, "after": preview,
|
||||
})
|
||||
return
|
||||
new_layout = {**layout, **preview}
|
||||
new_rev = current_rev + 1
|
||||
# the backup rides the same store write: either both are durable or
|
||||
# neither — the deletion-shy rules of this project apply to positions
|
||||
# too
|
||||
await rt.store.async_save({
|
||||
"layout": new_layout, "rev": new_rev,
|
||||
"repair_backup": {"space": space_id, "positions": touched},
|
||||
})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "moved": len(preview)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -183,17 +293,18 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
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
|
||||
# a different file may already own this name — do NOT silently point
|
||||
# the url at it. The shared helper CLAIMS a free one atomically, so
|
||||
# a concurrent migrate or upload cannot pick the same one, and the
|
||||
# name it returns is one the content view accepts back in a request.
|
||||
name = reserve_filename(dst, f.name)
|
||||
target = dst / name
|
||||
try:
|
||||
shutil.copy2(str(f), str(target))
|
||||
except OSError:
|
||||
target.unlink(missing_ok=True) # never leave an empty placeholder
|
||||
raise
|
||||
mapping[f.name] = name
|
||||
return mapping
|
||||
|
||||
try:
|
||||
@@ -204,6 +315,105 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/plans/list"})
|
||||
@websocket_api.async_response
|
||||
async def ws_plans_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Plan images on the server, with what still uses them.
|
||||
|
||||
Files are never removed for being unreferenced (docs/SCOPE.md), which only
|
||||
works as a policy if the user can see them: detaching a plan keeps the
|
||||
image, and this is how it gets picked up again — or deleted on purpose.
|
||||
"""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
used: dict[str, list[str]] = {}
|
||||
for space in cfg.get("spaces") or []:
|
||||
name = plan_basename(space.get("plan_url"))
|
||||
if name:
|
||||
used.setdefault(name, []).append(space.get("title") or space.get("id") or "?")
|
||||
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
|
||||
def _scan() -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
if not plans_dir.is_dir():
|
||||
return out
|
||||
for item in sorted(plans_dir.iterdir()):
|
||||
if not item.is_file() or not is_plan_file(item.name):
|
||||
continue
|
||||
try:
|
||||
st = item.stat()
|
||||
except OSError:
|
||||
continue
|
||||
out.append({
|
||||
"name": item.name,
|
||||
"url": f"{CONTENT_URL}/plans/_/{item.name}",
|
||||
"size": st.st_size,
|
||||
"modified": int(st.st_mtime),
|
||||
"used_by": used.get(item.name, []),
|
||||
})
|
||||
out.sort(key=lambda x: -x["modified"])
|
||||
return out
|
||||
|
||||
plans = await hass.async_add_executor_job(_scan)
|
||||
# newest first and capped: a folder with thousands of files would otherwise
|
||||
# become one huge message, one huge list and a signing request per row
|
||||
connection.send_result(
|
||||
msg["id"], {"plans": plans[:MAX_PLANS_LISTED], "total": len(plans)}
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plans/delete",
|
||||
vol.Required("name"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_plans_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete a plan image because the user asked — the only way one goes.
|
||||
|
||||
Refuses while a space still references it: the answer to "can I delete this"
|
||||
is the stored configuration's, not the client's.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete plans")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
name = sanitize_filename(msg["name"])
|
||||
if not is_plan_file(name):
|
||||
connection.send_error(msg["id"], "invalid_name", "Not a plan file")
|
||||
return
|
||||
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
if name in plan_refs(cfg):
|
||||
connection.send_error(
|
||||
msg["id"], "in_use", "A space still uses this plan — detach it first"
|
||||
)
|
||||
return
|
||||
path = Path(hass.config.path(PLANS_DIR)) / name
|
||||
|
||||
def _rm() -> bool:
|
||||
try:
|
||||
path.unlink()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not delete %s: %s", path, err)
|
||||
return False
|
||||
|
||||
removed = await hass.async_add_executor_job(_rm)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/content/sign",
|
||||
@@ -252,34 +462,68 @@ async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete a marker's file folder — called only AFTER the config is committed."""
|
||||
"""Drop a marker folder's leftovers after its files moved elsewhere.
|
||||
|
||||
Called after a rebind: the files were copied to the new marker id and the
|
||||
config that references them is committed, so the source folder is spent.
|
||||
|
||||
It used to `rmtree` the folder on the client's word alone. Two ways that
|
||||
ends badly: a partial copy leaves some urls still pointing INTO this folder
|
||||
(the migration deliberately does not rewrite those), and a wrong or stale
|
||||
id from any client deletes a live marker's attachments outright. So the
|
||||
server checks for itself — under the config lock — and removes only files
|
||||
the stored configuration does not reference. Same principle as the
|
||||
collector: a client may say what it no longer needs, never what may go.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
|
||||
return
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
from .const import FILES_DIR
|
||||
from .plans import attachment_refs
|
||||
from .validation import sanitize_marker_id
|
||||
|
||||
mid = sanitize_marker_id(msg["marker_id"])
|
||||
if not mid:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": False})
|
||||
return
|
||||
base = Path(hass.config.path(FILES_DIR)).resolve()
|
||||
target = (base / mid).resolve()
|
||||
if not str(target).startswith(str(base)) or target == base:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": False})
|
||||
target = (base / mid).resolve() if mid else base
|
||||
if not mid or not str(target).startswith(str(base)) or target == base:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": 0, "kept": 0})
|
||||
return
|
||||
|
||||
def _rm() -> bool:
|
||||
if not target.is_dir():
|
||||
return False
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
return True
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
refs = attachment_refs(stored.get("config") or {})
|
||||
|
||||
removed = await hass.async_add_executor_job(_rm)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
def _rm() -> tuple[int, int]:
|
||||
if not target.is_dir():
|
||||
return 0, 0
|
||||
removed = kept = 0
|
||||
for item in sorted(target.iterdir()):
|
||||
if not item.is_file():
|
||||
continue
|
||||
if f"{mid}/{item.name}" in refs:
|
||||
kept += 1
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove %s: %s", item, err)
|
||||
if not kept:
|
||||
try:
|
||||
target.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return removed, kept
|
||||
|
||||
removed, kept = await hass.async_add_executor_job(_rm)
|
||||
if kept:
|
||||
_LOGGER.info(
|
||||
"House Plan: kept %s file(s) in %s — the configuration still references them", kept, mid
|
||||
)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed, "kept": kept})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -297,13 +541,18 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
new_rev: int | None = None
|
||||
async with rt.write_lock:
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout", {})
|
||||
if msg["device_id"] in layout:
|
||||
del layout[msg["device_id"]]
|
||||
await rt.store.async_save({"layout": layout})
|
||||
connection.send_result(msg["id"], {"ok": True})
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"layout": layout, "rev": new_rev})
|
||||
if new_rev is not None:
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
# ---------------- space configuration ----------------
|
||||
@@ -322,6 +571,46 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
|
||||
|
||||
|
||||
def _internal_plan_names(config: dict[str, Any]) -> set[str]:
|
||||
"""Plan file names a configuration names through OUR urls.
|
||||
|
||||
Only `/api/houseplan/content/plans/_/<name>` and the legacy static path
|
||||
count. Anything else belongs to the user and may point wherever they like.
|
||||
"""
|
||||
out: set[str] = set()
|
||||
for space in (config or {}).get("spaces") or []:
|
||||
url = space.get("plan_url")
|
||||
if not isinstance(url, str) or not url:
|
||||
continue
|
||||
if not (url.startswith(CONTENT_URL + "/plans/") or url.startswith(PLANS_URL + "/")):
|
||||
continue
|
||||
name = plan_basename(url)
|
||||
if name:
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
def _missing_internal_plans(
|
||||
plans_dir: Path, config: dict[str, Any], previous: dict[str, Any] | None = None
|
||||
) -> set[str]:
|
||||
"""Newly named plan files that are not on disk.
|
||||
|
||||
Guards the pick-then-save window: another client may delete a plan between
|
||||
the moment this one chose it and the moment it saves, which would otherwise
|
||||
store a url with nothing behind it (HP-1470-02).
|
||||
|
||||
A name the stored configuration already carries is deliberately let through.
|
||||
It is already broken — repairs says so — and refusing the write would lock
|
||||
the owner out of every other edit, including the one that detaches it.
|
||||
"""
|
||||
known = _internal_plan_names(previous or {})
|
||||
return {
|
||||
name
|
||||
for name in _internal_plan_names(config)
|
||||
if name not in known and not (plans_dir / name).is_file()
|
||||
}
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/set",
|
||||
@@ -343,6 +632,16 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
# Per-field limits bound each list; this bounds their product (HP-1454-05).
|
||||
# Everything below the caps can still add up to something no dashboard can
|
||||
# render, and the store writes it to disk on every save.
|
||||
size = len(json.dumps(msg["config"], separators=(",", ":")))
|
||||
if size > MAX_CONFIG_BYTES:
|
||||
connection.send_error(
|
||||
msg["id"], "too_large",
|
||||
f"Configuration is {size // 1024} KB, the limit is {MAX_CONFIG_BYTES // 1024} KB",
|
||||
)
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.config_store.async_load() or {}
|
||||
current_rev = data.get("rev", 0)
|
||||
@@ -361,6 +660,23 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
|
||||
)
|
||||
return
|
||||
# An internal plan url must name a file that exists. The card can pick a
|
||||
# plan and then delete it from the same dialog, and two clients can do
|
||||
# the same thing in either order — the lock serialises them but says
|
||||
# nothing about whether the file survived (HP-1470-02). External and
|
||||
# legacy urls are not ours to check and are left alone.
|
||||
missing = await hass.async_add_executor_job(
|
||||
_missing_internal_plans,
|
||||
Path(hass.config.path(PLANS_DIR)),
|
||||
msg["config"],
|
||||
data.get("config"),
|
||||
)
|
||||
if missing:
|
||||
connection.send_error(
|
||||
msg["id"], "missing_plan",
|
||||
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||
)
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
# Still holding the lock: the file system is not part of the store's
|
||||
@@ -369,13 +685,16 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# failure here must not withhold the event and the success response,
|
||||
# or the client retries an edit the server has already accepted and
|
||||
# gets a conflict for its trouble (R4-1).
|
||||
def _collect() -> None:
|
||||
collect_plans(Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"])
|
||||
collect_attachments(Path(hass.config.path(FILES_DIR)), data.get("config"), msg["config"])
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
|
||||
)
|
||||
await hass.async_add_executor_job(_collect)
|
||||
except Exception: # noqa: BLE001 — see above: the commit stands regardless
|
||||
_LOGGER.exception("House Plan: collecting superseded plan files failed")
|
||||
_LOGGER.exception("House Plan: collecting superseded files failed")
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
# refresh repair issues (broken plan references) without waiting for a restart
|
||||
entry = get_entry(hass)
|
||||
if entry is not None:
|
||||
@@ -419,10 +738,11 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
# deleted here (review R2-1). The old name stays readable, so a config write
|
||||
# that is later rejected — revision conflict, validation, lost connection —
|
||||
# leaves the stored plan exactly as it was. The card calls
|
||||
# nothing here; the superseded file is collected by `config/set` itself,
|
||||
# inside the write lock, once a revision that no longer references it has
|
||||
# been accepted (review R3-1). A crash in between leaves an orphan, which
|
||||
# the same collector removes on a later commit once it is old enough.
|
||||
# nothing here; the file a commit REPLACES is collected by `config/set`
|
||||
# itself, inside the write lock (review R3-1). An upload that never gets
|
||||
# committed is not collected at all — it is offered back in the space
|
||||
# dialog's "already uploaded" list, where the user can attach or delete it.
|
||||
# Every attempt to age these out ended in data loss or a race (v1.46.4-6).
|
||||
#
|
||||
# `.` separates the id from the token because a space id cannot contain one
|
||||
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
|
||||
@@ -431,9 +751,37 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
|
||||
path = plans_dir / name
|
||||
|
||||
def _write() -> None:
|
||||
def _check_and_write() -> None:
|
||||
# one executor job for the pair, under upload_lock: the measurement
|
||||
# is only a bound if nothing else writes between it and our write
|
||||
# (HP-1490-02). A failed write reserves nothing — the file either
|
||||
# exists and is counted by the next scan, or does not and is not.
|
||||
check_quota(plans_dir, len(raw), MAX_PLANS_BYTES, MAX_PLANS_FILES)
|
||||
plans_dir.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(raw)
|
||||
|
||||
await hass.async_add_executor_job(_write)
|
||||
data = _runtime(hass, connection, msg["id"])
|
||||
if data is None:
|
||||
return
|
||||
async with data.upload_lock:
|
||||
try:
|
||||
await hass.async_add_executor_job(_check_and_write)
|
||||
except QuotaError as err:
|
||||
connection.send_error(msg["id"], err.reason, err.detail)
|
||||
return
|
||||
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
|
||||
|
||||
|
||||
def _refresh_trail_recorder(hass: HomeAssistant) -> None:
|
||||
"""Markers changed — the trail recorder must re-resolve what it watches."""
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
if rec:
|
||||
hass.async_create_task(rec.async_refresh())
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/trail/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
|
||||
"""Current + previous cleanup runs per marker, raw robot coordinates."""
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
connection.send_result(msg["id"], {"trails": rec.book.data if rec else {}})
|
||||
|
||||
@@ -56,6 +56,10 @@ export async function launch(viewport = { width: 820, height: 760 }, scale = 1)
|
||||
});
|
||||
await page.goto('http://demo.local/demo.html', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 });
|
||||
// HP-1552: the first-open boot veil hides the plan (visibility:hidden) until
|
||||
// the stage height settles — real pointer interaction cannot hit a hidden
|
||||
// plan, so every smoke starts where the user does: with the plan revealed.
|
||||
await page.waitForFunction(() => window.__card._booting === false, { timeout: 9000 });
|
||||
// hass flows continuously in production; the stub sets it once — nudge a rebuild
|
||||
await page.evaluate(() => { const c = window.__card; c.hass = { ...c.hass }; });
|
||||
await page.waitForFunction(() => window.__card._devices.length > 0, { timeout: 9000 });
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Capture: a curtain on the move — the breathing ring (.covermove) around a
|
||||
// NEUTRAL plate (owner 2026-08-03). The pulse is frozen at a visible frame so
|
||||
// the shot is deterministic.
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] };
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'cover.gate': { entity_id: 'cover.gate', state: 'opening',
|
||||
attributes: { friendly_name: 'Curtain', device_class: 'curtain' } } } };
|
||||
c._setMode('view'); c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const r = (c.shadowRoot || c.renderRoot).querySelector('.dev.covermove').getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
const pad = 70;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/cover_move.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.w + pad * 2, height: box.h + pad * 2 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,69 @@
|
||||
// Capture: one curtain in the four states an owner sees — closed, open,
|
||||
// opening, closing (owner's contract 2026-08-04). The plate is the plain
|
||||
// neutral badge in ALL of them; open/closed is told by the icon morph alone,
|
||||
// and the two travelling ones add the breathing .covermove ring (frozen at a
|
||||
// visible frame so the shot is deterministic).
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 520 }, 2);
|
||||
const STATES = ['closed', 'open', 'opening', 'closing'];
|
||||
await page.evaluate(async (STATES) => {
|
||||
const c = window.__card;
|
||||
const devices = {}; const entities = {}; const states = {};
|
||||
STATES.forEach((s, i) => {
|
||||
const id = 'd_cur' + i;
|
||||
devices[id] = { id, name: 'Curtain ' + s, model: 'Roller shade driver E1',
|
||||
area_id: 'garden', identifiers: [['demo', id]], entry_type: null, via_device_id: null };
|
||||
entities['cover.cur' + i] = { entity_id: 'cover.cur' + i, device_id: id, platform: 'demo' };
|
||||
states['cover.cur' + i] = { entity_id: 'cover.cur' + i, state: s,
|
||||
attributes: { friendly_name: 'Curtain ' + s, device_class: 'curtain' } };
|
||||
});
|
||||
c.hass = { ...c.hass,
|
||||
devices: { ...c.hass.devices, ...devices },
|
||||
entities: { ...c.hass.entities, ...entities },
|
||||
states: { ...c.hass.states, ...states } };
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
...STATES.map((s, i) => ({ id: 'm_cur' + i, binding: 'device:d_cur' + i, tap_action: 'cover' })),
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
{ id: 'm_gate', binding: 'device:d_gate', hidden: true },
|
||||
] };
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
const layout = { ...c._layout };
|
||||
STATES.forEach((s, i) => {
|
||||
const d = c._devices.find((x) => x.bindingRef === 'd_cur' + i);
|
||||
if (d) layout[d.id] = { s: 'garden', x: 0.18 + i * 0.215, y: 0.5 };
|
||||
});
|
||||
c._layout = layout;
|
||||
c._setMode('view'); c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
}, STATES);
|
||||
await page.waitForTimeout(500);
|
||||
// captions under each badge, so the four states are readable in the file
|
||||
const box = await page.evaluate((STATES) => {
|
||||
const c = window.__card;
|
||||
const els = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.devlayer .dev')]
|
||||
.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x);
|
||||
let minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
|
||||
els.forEach((el, i) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const cap = document.createElement('div');
|
||||
cap.textContent = STATES[i];
|
||||
cap.style.cssText = `position:fixed;left:${r.x + r.width / 2}px;top:${r.y + r.height + 10}px;`
|
||||
+ 'transform:translateX(-50%);font:600 13px system-ui,sans-serif;color:#1c2530;'
|
||||
+ 'letter-spacing:.02em;z-index:9999;padding:2px 7px;border-radius:6px;'
|
||||
+ 'background:rgba(255,255,255,0.88);white-space:nowrap;';
|
||||
document.body.appendChild(cap);
|
||||
minX = Math.min(minX, r.x); maxX = Math.max(maxX, r.x + r.width);
|
||||
minY = Math.min(minY, r.y); maxY = Math.max(maxY, r.y + r.height);
|
||||
});
|
||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
||||
}, STATES);
|
||||
const pad = 55;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/cover_states.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad),
|
||||
width: box.w + pad * 2, height: box.h + pad * 2 + 22 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,36 @@
|
||||
// Three stills of the daynight scale (noon/sunset/night) for the white-day review.
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 700, height: 700 }, 1);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.plan_url = ''; // hand-drawn plan: white paper — the risky case for a white day
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
});
|
||||
const shot = async (az, el, name) => {
|
||||
await page.evaluate(async ([az, el]) => {
|
||||
const c = window.__card;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth: az, elevation: el },
|
||||
} } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
}, [az, el]);
|
||||
await page.waitForTimeout(300);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${name}.png` });
|
||||
};
|
||||
await shot(180, 60, 'dn_noon');
|
||||
await shot(265, 3, 'dn_sunset');
|
||||
await shot(0, -20, 'dn_night');
|
||||
await browser.close();
|
||||
console.log('shots done');
|
||||
@@ -0,0 +1,31 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
// the flash is keyed to a WITNESSED off→on transition (owner's rule
|
||||
// 2026-08-01: разовая вспышка в момент обнаружения) — cycle through off
|
||||
const set = async (state) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state,
|
||||
attributes: { friendly_name: 'Motion', device_class: 'motion', linkquality: 64 } } } };
|
||||
await c.updateComplete;
|
||||
};
|
||||
await set('off');
|
||||
await set('on');
|
||||
// freeze the pulse at a visible frame for a deterministic capture
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.dev.senseflash::after{animation-delay:-0.4s;animation-play-state:paused;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const el = (c.shadowRoot || c.renderRoot).querySelector('.dev.senseflash');
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
const pad = 70;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/motion_sense.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.w + pad * 2, height: box.h + pad * 2 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,27 @@
|
||||
// Capture: the shoulder rulers + centre tick while PLACING a new opening
|
||||
// (Opening tool, cursor hovering the wall centre) — owner 2026-08-03.
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [];
|
||||
c._setMode('plan'); c._tool = 'opening';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const pt = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const stage = c.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return { x: r.left + ((293.7 - vx) / vw) * r.width, y: r.top + ((141 - vy) / vh) * r.height,
|
||||
sx: r.left, sy: r.top, sw: r.width, sh: r.height };
|
||||
});
|
||||
await page.mouse.move(pt.x, pt.y, { steps: 4 });
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/opening_place.png',
|
||||
clip: { x: pt.sx, y: pt.sy, width: pt.sw, height: Math.min(pt.sh, 420) } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,52 @@
|
||||
// Screenshots for docs/SUN.md: evening wedges over the glow fill + the compass
|
||||
// dial in the general settings. Usage: node demo/shot_sun.mjs <outdir>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
// instant colors for the still shot: the day/night transition is tens of seconds
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
|
||||
c._serverCfg.settings = {
|
||||
...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight',
|
||||
};
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
// a warm evening: the sun low in the west; the living-room light off so
|
||||
// the glow pool does not swamp the west wedge
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
|
||||
'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 265, elevation: 3 },
|
||||
},
|
||||
} };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: outDir + '/sun_rays.png' });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._openSettingsDialog();
|
||||
c._settingsDialog = { ...c._settingsDialog, northDeg: 330 };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
const dlg = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.dialog'));
|
||||
await dlg.asElement().screenshot({ path: outDir + '/sun_compass.png' });
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -0,0 +1,46 @@
|
||||
// Brightness check for the wedges (owner 2026-08-03: «поярче, иногда плохо
|
||||
// видны»). Two hardest cases side by side: a DAYLIGHT sun on white paper (the
|
||||
// wedge colour is nearly neutral there) and a low sun over the dark glow
|
||||
// canvas. Usage: node demo/shot_sun_bright.mjs <outdir> [suffix]
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const tag = process.argv[3] ? '_' + process.argv[3] : '';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
// the fade is a 2 s animation; reduced motion pins the layer at full opacity
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
const setup = (fill, azimuth, elevation, ceiling) => page.evaluate(async ([fill, azimuth, elevation, ceiling]) => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: fill };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgEpoch++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: ceiling },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth, elevation } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, [fill, azimuth, elevation, ceiling]);
|
||||
|
||||
const shoot = async (name) => {
|
||||
await page.waitForTimeout(400);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${name}${tag}.png` });
|
||||
};
|
||||
|
||||
// 1) plain daylight on white paper: neutral wedge colour, brightest canvas
|
||||
await setup('none', 265, 24, 'off');
|
||||
await shoot('sun_bright_paper');
|
||||
// 2) a low warm sun over the dark glow canvas
|
||||
await setup('glow', 265, 4, 'off');
|
||||
await shoot('sun_bright_glow');
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -0,0 +1,48 @@
|
||||
// Stills for the 2026-08-04 ray report: 30 % shorter shafts with SHARP sides
|
||||
// that dissolve only along the ray (docs/SUN.md). The low-sun frame is the
|
||||
// interesting one — the north and south windows get a nearly wall-grazing sun,
|
||||
// the case that used to hang a bright kerb across the floor. Usage:
|
||||
// node demo/shot_sun_short.mjs <outdir> <prefix>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const tag = process.argv[3] || 'sun_rays_short';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no 45 s sky glide
|
||||
|
||||
const setup = async (az, el) => {
|
||||
await page.evaluate(async ([azimuth, elevation]) => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth, elevation } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, [az, el]);
|
||||
await page.waitForTimeout(350);
|
||||
};
|
||||
|
||||
// low sun in the west — the long shafts, the case that hit the far walls
|
||||
await setup(265, 5);
|
||||
let stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${tag}_low.png` });
|
||||
|
||||
// high southern sun — the short noon pools
|
||||
await setup(180, 55);
|
||||
stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${tag}_high.png` });
|
||||
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -33,7 +33,7 @@ const res = await page.evaluate(async () => {
|
||||
const a = devs[0], b = devs[1];
|
||||
const pa = c._pos(a);
|
||||
// поставим b на тот же Y, начнём drag
|
||||
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / (1000 / (c._curSpaceCfg.aspect || 1)) } };
|
||||
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / 1000 } };
|
||||
c._drag = { id: b.id, sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuide = guides() >= 1;
|
||||
@@ -42,7 +42,7 @@ const res = await page.evaluate(async () => {
|
||||
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
|
||||
const W = 1000, H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const W = 1000, H = 1000; // square canvas
|
||||
c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.decorGuide = guides() >= 1;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Аудит v1.49.0: HP-1490-03 (редакторы видят весь холст) и HP-1490-04
|
||||
// (Save ждёт пропорции выбранного сохранённого плана, старые не наследуются).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 1000 }, 1);
|
||||
const out = {};
|
||||
|
||||
// ---- HP-1490-03: content-fit только в просмотре -------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
// рукописное пространство: одна маленькая комната в центре квадрата
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
|
||||
cfg.spaces[0].rooms = [{ id: 'r1', name: 'One', area: 'living_room',
|
||||
poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }];
|
||||
c._serverCfg = cfg; c._model = null; c._view = null; c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inView = c._baseVb();
|
||||
o.viewIsContentFit = inView[2] < 999; // меньше холста (устройства тоже содержимое)
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inPlan = c._baseVb();
|
||||
// docs/CANVAS.md: холста-квадрата больше нет, поэтому «редактор видит весь
|
||||
// холст» переписано в исходное НАМЕРЕНИЕ HP-1490-03 — «в редакторе есть куда
|
||||
// рисовать наружу». Рамка теперь по содержимому и в редакторе тоже, а место
|
||||
// даёт запас панорамирования (§5) и зум-аут до 3x.
|
||||
o.editorFrameIsContent = inPlan[2] < 999 && inPlan[2] > 0;
|
||||
o.editorCanPanPastContent = (() => {
|
||||
const fit = c._viewOr(c._baseVb());
|
||||
const moved = c._clampView({ x: fit.x + fit.w * 0.9, y: fit.y, w: fit.w, h: fit.h }, fit);
|
||||
return moved.x > fit.x + fit.w * 0.5; // ушли почти на экран вправо, зажима нет
|
||||
})();
|
||||
o.editorZoomOutGivesRoom = (() => {
|
||||
c._resetZoom(); c._applyView(1 / 3);
|
||||
const wide = c._view.w; c._resetZoom();
|
||||
return wide > inPlan[2] * 2.5; // видно втрое больше, чем нарисовано
|
||||
})();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
// в редакторе можно ткнуть в дальний угол холста
|
||||
o.canReachFarCorner = (() => {
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1);
|
||||
return pt[0] > inPlan[0] + inPlan[2] * 0.9 || pt[1] > inPlan[1] + inPlan[3] * 0.9;
|
||||
})();
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const back = c._baseVb();
|
||||
o.contentFitRestored = back[2] < 999;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- HP-1490-04: Save ждёт aspect --------------------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
let saved = null;
|
||||
let signDelay = 500; // подпись приходит поздно
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plans/list') return { plans: [
|
||||
{ name: 'wide.svg', url: '/api/houseplan/content/plans/_/wide.svg', size: 10, modified: 1, used_by: [] },
|
||||
] };
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
await new Promise((r) => setTimeout(r, signDelay));
|
||||
const urls = {}; for (const p of m.paths) urls[p] = '/assets/wide.svg'; return { urls };
|
||||
}
|
||||
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
|
||||
if (m.type === 'houseplan/config/get') return { config: saved || c._serverCfg, rev: c._cfgRev || 0 };
|
||||
return base(m);
|
||||
} };
|
||||
// страница отдаёт /assets/wide.svg размером 800x200 (создан рядом)
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
|
||||
await c.updateComplete;
|
||||
c._useServerPlan('/api/houseplan/content/plans/_/wide.svg');
|
||||
o.oldAspectCleared = c._spaceDialog.savedAspect === undefined;
|
||||
// Save сразу, до прихода подписи
|
||||
const p = c._saveSpaceDialog();
|
||||
await p;
|
||||
o.savedUrl = saved?.spaces?.[0]?.plan_url === '/api/houseplan/content/plans/_/wide.svg';
|
||||
const a = saved?.spaces?.[0]?.plan_aspect;
|
||||
o.savedAspectIsReal = Math.abs((a || 0) - 4) < 0.01; // 800x200
|
||||
o.notTheOldAspect = a !== 1.25;
|
||||
return o;
|
||||
}));
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,345 @@
|
||||
// New setting "background color around the plan": global (config.settings.bg_color,
|
||||
// gear dialog) with a per-space override (space dialog, empty = inherit), applied
|
||||
// to the stage in view and kiosk modes and to the static space-card. Editors keep
|
||||
// their own canvas; the open dialogs preview the pending color live.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stageBg = async () => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
return getComputedStyle(sr().querySelector('.stage')).backgroundColor;
|
||||
};
|
||||
const rgb = (hex) => `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
|
||||
|
||||
const themeBg = await stageBg(); // theme default from the stylesheet
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
|
||||
// 1) global setting colors the stage
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#224466' };
|
||||
out.globalApplies = (await stageBg()) === rgb('#224466');
|
||||
|
||||
// 2) per-space override wins
|
||||
sp.settings = { ...(sp.settings || {}), bg_color: '#663311' };
|
||||
out.spaceOverrides = (await stageBg()) === rgb('#663311');
|
||||
|
||||
// 3) clearing the space setting inherits the global again
|
||||
delete sp.settings.bg_color;
|
||||
out.clearInheritsGlobal = (await stageBg()) === rgb('#224466');
|
||||
|
||||
// 4) clearing the global returns the theme default
|
||||
delete c._serverCfg.settings.bg_color;
|
||||
out.clearRestoresTheme = (await stageBg()) === themeBg;
|
||||
|
||||
// 5) invalid stored value is ignored (renderer never trusts garbage)
|
||||
c._serverCfg.settings.bg_color = 'url(javascript:alert(1))';
|
||||
out.garbageIgnored = (await stageBg()) === themeBg;
|
||||
delete c._serverCfg.settings.bg_color;
|
||||
|
||||
// 6) the settings dialog previews its pending color live + saves it
|
||||
let saved = null;
|
||||
const origWS = c.hass.callWS;
|
||||
c.hass.callWS = async (m) => {
|
||||
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
|
||||
return origWS.call(c.hass, m);
|
||||
};
|
||||
c._openSettingsDialog();
|
||||
await c.updateComplete;
|
||||
out.dialogHasBgRow = !!sr().querySelector('.dialog .colorrow input[type=color]');
|
||||
c._settingsDialog = { ...c._settingsDialog, bgColor: '#0a2a4a' };
|
||||
out.dialogPreviews = (await stageBg()) === rgb('#0a2a4a');
|
||||
await c._saveSettingsDialog();
|
||||
out.dialogSaves = saved?.settings?.bg_color === '#0a2a4a';
|
||||
out.savedApplies = (await stageBg()) === rgb('#0a2a4a');
|
||||
|
||||
// 7) the space dialog previews and saves the override
|
||||
c._openSpaceDialog('edit', c._space);
|
||||
await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, bgColor: '#4a0a2a' };
|
||||
out.spaceDialogPreviews = (await stageBg()) === rgb('#4a0a2a');
|
||||
saved = null;
|
||||
await c._saveSpaceDialog();
|
||||
const savedSp = saved?.spaces?.find((s) => s.id === c._space);
|
||||
out.spaceDialogSaves = savedSp?.settings?.bg_color === '#4a0a2a';
|
||||
out.spaceSavedApplies = (await stageBg()) === rgb('#4a0a2a');
|
||||
|
||||
// ...and clearing it in the dialog inherits the global again
|
||||
c._openSpaceDialog('edit', c._space);
|
||||
await c.updateComplete;
|
||||
out.spaceDialogPrefilled = c._spaceDialog.bgColor === '#4a0a2a';
|
||||
c._spaceDialog = { ...c._spaceDialog, bgColor: null };
|
||||
await c._saveSpaceDialog();
|
||||
// over the wire `undefined` keys vanish — check the JSON form, like the backend sees it
|
||||
const wire = JSON.parse(JSON.stringify(saved));
|
||||
const savedSp2 = wire?.spaces?.find((s) => s.id === c._space);
|
||||
out.spaceDialogClears = !('bg_color' in (savedSp2?.settings || {}));
|
||||
out.backToGlobal = (await stageBg()) === rgb('#0a2a4a');
|
||||
c.hass.callWS = origWS;
|
||||
|
||||
// 8) editors keep their own canvas (inline bg only in view/kiosk)
|
||||
c._setMode('decor');
|
||||
const editorBg = await stageBg();
|
||||
out.editorUnpainted = editorBg !== rgb('#0a2a4a');
|
||||
c._setMode('view');
|
||||
out.viewPaintedAgain = (await stageBg()) === rgb('#0a2a4a');
|
||||
|
||||
// 9) kiosk instance respects the setting
|
||||
const k = document.createElement('houseplan-card');
|
||||
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
k.hass = c.hass;
|
||||
k.style.cssText = 'position:fixed;left:0;top:0;width:860px;height:860px;z-index:99';
|
||||
document.body.appendChild(k);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
// the kiosk instance re-reads the pristine demo config — inject the color
|
||||
// into ITS copy; what matters here is the kiosk rendering path
|
||||
k._serverCfg.settings = { ...(k._serverCfg.settings || {}), bg_color: '#0a2a4a' };
|
||||
k.hass = { ...c.hass };
|
||||
k.requestUpdate();
|
||||
await k.updateComplete;
|
||||
const kStage = (k.shadowRoot || k.renderRoot).querySelector('.stage');
|
||||
out.kioskApplies = !!kStage && getComputedStyle(kStage).backgroundColor === rgb('#0a2a4a');
|
||||
k.remove();
|
||||
|
||||
// 10) the static space-card paints the same background
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.settings = { ...(cfg.settings || {}), bg_color: '#123456' };
|
||||
const hass2 = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
return origWS.call(c.hass, m);
|
||||
} };
|
||||
const scHost = document.createElement('div');
|
||||
document.body.appendChild(scHost);
|
||||
const sc = document.createElement('houseplan-space-card');
|
||||
sc.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
|
||||
sc.hass = hass2;
|
||||
scHost.appendChild(sc);
|
||||
const t0 = Date.now();
|
||||
while (!sc.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) await new Promise((r) => setTimeout(r, 80));
|
||||
await sc.updateComplete;
|
||||
const st = sc.renderRoot.querySelector('.hp-static-stage');
|
||||
out.staticCardApplies = !!st && getComputedStyle(st).backgroundColor === rgb('#123456');
|
||||
// ...and the space override beats the global there too
|
||||
cfg.spaces.find((s) => s.id === c._space).settings = { bg_color: '#654321' };
|
||||
sc.hass = { ...hass2 };
|
||||
sc._cfg = null; // force a config re-read
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
sc.requestUpdate(); await sc.updateComplete;
|
||||
const st2 = sc.renderRoot.querySelector('.hp-static-stage');
|
||||
out.staticCardSpaceWins = !!st2 && getComputedStyle(st2).backgroundColor === rgb('#654321');
|
||||
scHost.remove();
|
||||
|
||||
// tidy: drop the color so later smokes on this page see the theme default
|
||||
delete c._serverCfg.settings.bg_color;
|
||||
const spx = c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
if (spx?.settings) delete spx.settings.bg_color;
|
||||
await stageBg();
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
// ---- 11) opaque plan paper (owner 2026-08-03) --------------------------
|
||||
// The scene bg_color must never bleed through the plan itself: opaque
|
||||
// .hp-paper shapes sit under everything the plan draws. An IMAGE plan papers
|
||||
// the backdrop image rect (the canvas is the paper); a DRAWN plan papers the
|
||||
// ROOM CONTOURS — one shape per room, never their bounding box (section 12).
|
||||
// The scene colour is visible ONLY around the paper; the 'daynight' night
|
||||
// dims the paper via brightness only.
|
||||
const paper = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#ff00ff' };
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
await upd();
|
||||
const m = c._spaceModel();
|
||||
const p = sr().querySelector('.stage svg rect.hp-paper');
|
||||
out.paperExists = !!p;
|
||||
if (p) {
|
||||
const a = (n) => Number(p.getAttribute(n));
|
||||
out.paperHugsImage = Math.abs(a('x') - m.bg.x) < 1e-6 && Math.abs(a('y') - m.bg.y) < 1e-6
|
||||
&& Math.abs(a('width') - m.bg.w) < 1e-6 && Math.abs(a('height') - m.bg.h) < 1e-6;
|
||||
const cs = getComputedStyle(p);
|
||||
out.paperOpaque = cs.fillOpacity === '1' && cs.opacity === '1'
|
||||
&& cs.fill !== 'none' && !/rgba\(.*,\s*0\)/.test(cs.fill);
|
||||
}
|
||||
// editors keep the paper too (their canvas ignores bg_color anyway)
|
||||
c._setMode('decor');
|
||||
await upd();
|
||||
out.editorKeepsPaper = !!sr().querySelector('.stage svg rect.hp-paper');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
// daynight night: the paper dims via the brightness filter ONLY, never alpha
|
||||
c._serverCfg.settings.bg_mode = 'daynight';
|
||||
c._serverCfg.settings.north_deg = 0;
|
||||
c._cfgRev++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'below_horizon',
|
||||
attributes: { azimuth: 180, elevation: -20 },
|
||||
} } };
|
||||
await upd();
|
||||
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
|
||||
const pn = sr().querySelector('.stage svg rect.hp-paper');
|
||||
out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
|
||||
delete c._serverCfg.settings.bg_mode;
|
||||
delete c._serverCfg.settings.north_deg;
|
||||
c._cfgRev++;
|
||||
await upd();
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, paper);
|
||||
|
||||
// ---- 12) drawn plan: the paper follows the ROOM CONTOURS ---------------
|
||||
// Owner: «белая подложка должна быть по границам комнат» — an L-shaped house
|
||||
// plus a detached building must NOT grow a white bounding rectangle. One
|
||||
// paper shape per room, in exactly the room's own geometry; the acid scene
|
||||
// colour shows in the L's pocket and between the buildings, right up to the
|
||||
// exterior walls; inside the rooms it never shows. A live resize preview
|
||||
// (_rszPreview) moves the paper together with the dragged wall.
|
||||
const drawn = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
sp.plan_url = ''; // hand-drawn plan now
|
||||
// binary-exact fractions -> exact render coords (0.125*1000 = 125 etc.)
|
||||
sp.rooms = [
|
||||
{ id: 'rL', name: 'L', poly: [[0.125, 0.125], [0.625, 0.125], [0.625, 0.375], [0.375, 0.375], [0.375, 0.625], [0.125, 0.625]] },
|
||||
{ id: 'rQ', name: 'Q', x: 0.75, y: 0.75, w: 0.1875, h: 0.1875 },
|
||||
];
|
||||
sp.openings = [];
|
||||
sp.decor = [];
|
||||
c._devices = []; // no icons over the pixel probes below
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
const papers = () => [...sr().querySelectorAll('.stage.noplan svg .hp-paper')];
|
||||
let ps = papers();
|
||||
out.paperPerRoom = ps.length === sp.rooms.length;
|
||||
out.paperShapesMatchRooms = ps.length === 2
|
||||
&& ps[0].tagName.toLowerCase() === 'polygon'
|
||||
&& ps[0].getAttribute('points') === sp.rooms[0].poly.map((p) => (p[0] * 1000) + ',' + (p[1] * 1000)).join(' ')
|
||||
&& ps[1].tagName.toLowerCase() === 'rect';
|
||||
out.drawnPaperWhite = ps.length > 0 && ps.every((p) => getComputedStyle(p).fill === 'rgb(255, 255, 255)');
|
||||
out.paperNoStroke = ps.length > 0 && ps.every((p) => {
|
||||
const cs = getComputedStyle(p);
|
||||
return cs.stroke === 'none' || cs.strokeOpacity === '0' || cs.strokeWidth === '0px';
|
||||
});
|
||||
// paper first, everything else on top of it (one .hp-paperg group wraps
|
||||
// all paper shapes so the daynight drop shadow composites without seams)
|
||||
const first = sr().querySelector('.stage.noplan svg').firstElementChild;
|
||||
out.paperUnderneath = !!first && first.classList.contains('hp-paperg')
|
||||
&& !!first.firstElementChild && first.firstElementChild.classList.contains('hp-paper');
|
||||
// live resize preview: drag the L's right wall 0.625 -> 0.6875 — the paper
|
||||
// must move WITH the room, not lag behind until the drop
|
||||
const pv = JSON.parse(JSON.stringify(sp));
|
||||
pv.rooms[0].poly = pv.rooms[0].poly.map(([x, y]) => [x === 0.625 ? 0.6875 : x, y]);
|
||||
c._rszPreview = { space: c._space, sp: pv };
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
out.previewMovesPaper = (papers()[0]?.getAttribute('points') || '').includes('687.5,125');
|
||||
c._rszPreview = null;
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
out.previewRestores = (papers()[0]?.getAttribute('points') || '').includes('625,125');
|
||||
// screen coords for the pixel probes (svg user units -> viewport px)
|
||||
const svgEl = sr().querySelector('.stage svg');
|
||||
const ctm = svgEl.getScreenCTM();
|
||||
const toScr = (x, y) => { const q = svgEl.createSVGPoint(); q.x = x; q.y = y; const r = q.matrixTransform(ctm); return [r.x, r.y]; };
|
||||
out.probes = {
|
||||
pocket: toScr(500, 500), // the L's notch — bg must show here
|
||||
gap: toScr(687, 300), // between the two buildings
|
||||
inside: [toScr(250, 250), toScr(500, 250), toScr(250, 500), toScr(843.75, 843.75)],
|
||||
};
|
||||
return out;
|
||||
});
|
||||
const { probes } = drawn;
|
||||
delete drawn.probes;
|
||||
Object.assign(res, drawn);
|
||||
|
||||
// pixel proof: the acid scene colour IS in the L's pocket and between the
|
||||
// buildings (where the old bounding-box paper drew a white square), and is
|
||||
// NOT inside any room
|
||||
if (probes) {
|
||||
const shot = (await page.screenshot()).toString('base64');
|
||||
const px = await page.evaluate(async ({ shot, probes }) => {
|
||||
const img = new Image();
|
||||
img.src = 'data:image/png;base64,' + shot;
|
||||
await img.decode();
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = img.width;
|
||||
cv.height = img.height;
|
||||
const ctx = cv.getContext('2d', { willReadFrequently: true });
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const at = ([x, y]) => [...ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data];
|
||||
const acid = ([r, g, b]) => r > 200 && b > 200 && g < 160; // #ff00ff admixture
|
||||
return {
|
||||
acidInPocket: acid(at(probes.pocket)),
|
||||
acidBetweenBuildings: acid(at(probes.gap)),
|
||||
noAcidInsideRooms: probes.inside.every((q) => !acid(at(q))),
|
||||
};
|
||||
}, { shot, probes });
|
||||
Object.assign(res, px);
|
||||
} else {
|
||||
res.acidInPocket = false;
|
||||
res.acidBetweenBuildings = false;
|
||||
res.noAcidInsideRooms = false;
|
||||
}
|
||||
|
||||
// ---- 13) the static space-card follows the same paper contract ----------
|
||||
const stat = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
// NOTE: the config-store cache is module-level and already primed by the
|
||||
// earlier static-card sections (with the PRE-§12 rooms), so a mocked
|
||||
// callWS would never reach the card — inject the snapshot directly.
|
||||
const mk = async (cfg) => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const el = document.createElement('houseplan-space-card');
|
||||
el.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
|
||||
el._snap = { config: cfg, rev: 1, layout: {} };
|
||||
el._loadedOnce = true;
|
||||
el.hass = c.hass;
|
||||
host.appendChild(el);
|
||||
const t0 = Date.now();
|
||||
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000)
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await el.updateComplete;
|
||||
return { el, host };
|
||||
};
|
||||
const base = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
base.spaces.find((s) => s.id === c._space).plan_url = '/assets/f1.svg';
|
||||
base.settings = { ...(base.settings || {}), bg_color: '#ff00ff' };
|
||||
const a = await mk(base);
|
||||
const pa = a.el.renderRoot.querySelector('.hp-static-stage svg rect.hp-paper');
|
||||
out.staticPaperExists = !!pa;
|
||||
out.staticPaperOpaque = !!pa && getComputedStyle(pa).fillOpacity === '1'
|
||||
&& getComputedStyle(pa).fill !== 'none';
|
||||
a.host.remove();
|
||||
// drawn plan: per-room paper shapes (the L + the detached rect from §12)
|
||||
const drawnCfg = JSON.parse(JSON.stringify(base));
|
||||
const dsp = drawnCfg.spaces.find((s) => s.id === c._space);
|
||||
dsp.plan_url = '';
|
||||
const b = await mk(drawnCfg);
|
||||
const pbs = [...b.el.renderRoot.querySelectorAll('.hp-static-stage svg .hp-paper')];
|
||||
out.staticDrawnPaperPerRoom = pbs.length === dsp.rooms.length;
|
||||
out.staticDrawnPaperShapes = pbs.length === 2
|
||||
&& pbs[0].tagName.toLowerCase() === 'polygon'
|
||||
&& pbs[1].tagName.toLowerCase() === 'rect';
|
||||
b.host.remove();
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, stat);
|
||||
checkAll({ ...paper, ...drawn, ...stat,
|
||||
acidInPocket: res.acidInPocket,
|
||||
acidBetweenBuildings: res.acidBetweenBuildings,
|
||||
noAcidInsideRooms: res.noAcidInsideRooms });
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,212 @@
|
||||
// The content frame is PRESENTATION (docs/CANVAS.md §4) — audit dev@2c947f4.
|
||||
//
|
||||
// DEV-2C947-01: a hidden device is not drawn, but it still stretched the
|
||||
// frame — hiding a marker that had wandered into the yard left the visible
|
||||
// house a dot in the corner (the full card AND the static
|
||||
// houseplan-space-card, which drew `devs` but framed `spaceDevs`).
|
||||
//
|
||||
// DEV-2C947-02: inside an editor the frame only grows (a frame that shrank
|
||||
// mid-drag would move the ground under the pointer); that union was memoised
|
||||
// without the mode, so it survived the way back into View.
|
||||
//
|
||||
// DEV-2C947-03: a room-outlier is rejected from the frame, but iconUnit()
|
||||
// measured ALL rooms — so the stray it had just rejected still blew every
|
||||
// marker of the main plan up ~91x.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
const out = {};
|
||||
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
// Load a config/layout into the card AND into the demo's WS stub, so the
|
||||
// static card (which fetches its own copy) sees exactly the same house.
|
||||
const load = (cfg, layout, space) => page.evaluate(async ([c2, l2, sp]) => {
|
||||
const c = window.__card;
|
||||
const srvCfg = (await c.hass.callWS({ type: 'houseplan/config/get' })).config;
|
||||
const srvLay = (await c.hass.callWS({ type: 'houseplan/layout/get' })).layout;
|
||||
for (const k of Object.keys(srvCfg)) delete srvCfg[k];
|
||||
Object.assign(srvCfg, JSON.parse(JSON.stringify(c2)));
|
||||
for (const k of Object.keys(srvLay)) delete srvLay[k];
|
||||
Object.assign(srvLay, JSON.parse(JSON.stringify(l2)));
|
||||
c._serverCfg = srvCfg;
|
||||
c._layout = JSON.parse(JSON.stringify(l2));
|
||||
c._modelCache = null;
|
||||
c._frame = null;
|
||||
c._showFar = false;
|
||||
c._space = sp;
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c._view = null;
|
||||
c._zoom = 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._resetZoom();
|
||||
await c.updateComplete;
|
||||
}, [cfg, layout, space]);
|
||||
|
||||
// ---------------------------------------------------------------- 01: hidden
|
||||
// The auditor's probe, verbatim: ONE visible room and ONE marker parked 90
|
||||
// canvases away. Two content items is below MIN_VOTERS, so the outlier vote
|
||||
// never runs and the far position is guaranteed to stay in the core — the
|
||||
// frame either sees the marker or it does not.
|
||||
const oneRoom = (hidden) => ({
|
||||
spaces: [{
|
||||
id: 'h1', title: 'Hidden', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
|
||||
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]] }],
|
||||
}],
|
||||
// a made-up area: no auto device of the demo house lands in this space, so
|
||||
// the only content is the room and the one marker below
|
||||
markers: [{ id: 'm_far', binding: 'device:d_motion', area: 'zx_room', hidden }],
|
||||
settings: { filter_seeded: true },
|
||||
});
|
||||
const FAR_POS = { m_far: { s: 'h1', x: 90, y: 0.5 } };
|
||||
|
||||
await load(oneRoom(false), FAR_POS, 'h1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const b = c._baseVb();
|
||||
return {
|
||||
// premise: while it IS drawn, the marker is content and the frame holds it
|
||||
visibleMarkerIsDrawn: sr.querySelectorAll('.devlayer .dev').length === 1,
|
||||
visibleMarkerWidensTheFrame: b[2] > 50000,
|
||||
};
|
||||
}));
|
||||
|
||||
await load(oneRoom(true), FAR_POS, 'h1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
const b = c._baseVb();
|
||||
o.hiddenMarkerNotDrawn = sr.querySelectorAll('.devlayer .dev').length === 0;
|
||||
o.hiddenMarkerStillBuilt = !!c._devices.find((d) => d.id === 'm_far' && d.hidden === true);
|
||||
// the room is 100..900 render units, padded by 5 % of the longer side
|
||||
o.frameIsTheVisibleRoom = Math.round(b[0]) === 60 && Math.round(b[2]) === 880
|
||||
&& Math.round(b[1]) === 60 && Math.round(b[3]) === 880;
|
||||
// and the room fills the screen instead of being a dot 112x too small
|
||||
const st = sr.querySelector('.stage').getBoundingClientRect();
|
||||
const poly = sr.querySelector('polygon.room').getBoundingClientRect();
|
||||
o.roomFillsTheStage = poly.width > st.width * 0.7;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// the same house on the STATIC card (space-render.ts drew `devs` but framed
|
||||
// `spaceDevs` — the identical split)
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const o = {};
|
||||
const host = document.createElement('div');
|
||||
host.style.cssText = 'width:400px;height:400px';
|
||||
document.body.appendChild(host);
|
||||
const el = document.createElement('houseplan-space-card');
|
||||
el.setConfig({ type: 'custom:houseplan-space-card', space: 'h1' });
|
||||
el.hass = window.__card.hass;
|
||||
host.appendChild(el);
|
||||
const t0 = Date.now();
|
||||
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
}
|
||||
await el.updateComplete;
|
||||
const svg = el.renderRoot.querySelector('.hp-static-stage svg');
|
||||
const vb = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
||||
o.staticCardHidesTheMarker = el.renderRoot.querySelectorAll('.devlayer .dev').length === 0;
|
||||
o.staticFrameIsTheVisibleRoom = vb.length === 4
|
||||
&& Math.round(vb[0]) === 60 && Math.round(vb[2]) === 880;
|
||||
host.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ------------------------------------------------------- 02: editor -> view
|
||||
const oneRoomAt = (x0) => ({
|
||||
spaces: [{
|
||||
id: 'e1', title: 'Editor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
|
||||
poly: [[x0, 0.1], [x0 + 0.8, 0.1], [x0 + 0.8, 0.9], [x0, 0.9]] }],
|
||||
}],
|
||||
markers: [], settings: { filter_seeded: true },
|
||||
});
|
||||
|
||||
await load(oneRoomAt(0.1), {}, 'e1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const round = () => c._baseVb().map((n) => Math.round(n));
|
||||
o.viewFrameBefore = JSON.stringify(round()) === JSON.stringify([60, 60, 880, 880]);
|
||||
// move the only room 5 canvases to the right, inside the Plan editor
|
||||
c._setMode('plan');
|
||||
await c.updateComplete;
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].rooms[0].poly = [[5.1, 0.1], [5.9, 0.1], [5.9, 0.9], [5.1, 0.9]];
|
||||
c._serverCfg = cfg;
|
||||
c._modelCache = null;
|
||||
c._cfgEpoch++;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const inEditor = round();
|
||||
// inside the editor the frame is the UNION — that is deliberate: it bounds
|
||||
// pan and defines zoom 1, and it must not shrink under a live gesture
|
||||
o.editorFrameOnlyGrows = inEditor[2] > 5000;
|
||||
c._setMode('view');
|
||||
await c.updateComplete;
|
||||
const back = round();
|
||||
o.viewFrameFollowsTheRoomAgain = JSON.stringify(back) === JSON.stringify([5060, 60, 880, 880]);
|
||||
if (!o.viewFrameFollowsTheRoomAgain) console.log('frame after exit', back, 'editor', inEditor);
|
||||
// and back into an editor it starts from the CURRENT geometry, not the old union
|
||||
c._setMode('plan');
|
||||
await c.updateComplete;
|
||||
o.reenteringTheEditorDoesNotResurrectTheUnion =
|
||||
JSON.stringify(round()) === JSON.stringify([5060, 60, 880, 880]);
|
||||
c._setMode('view');
|
||||
await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------- 03: icons and outliers
|
||||
// Three rooms in the core plus one dragged 90 canvases out. The frame rejects
|
||||
// the stray (that part always worked); the ICON UNIT used to measure all four
|
||||
// rooms, so every marker of the plan the user is looking at came out ~91x too
|
||||
// big and covered the house.
|
||||
const withFarRoom = (far) => ({
|
||||
spaces: [{
|
||||
id: 'i1', title: 'Icons', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [
|
||||
{ id: 'r1', name: 'A', area: 'zx_a', poly: [[0.10, 0.10], [0.30, 0.10], [0.30, 0.30], [0.10, 0.30]] },
|
||||
{ id: 'r2', name: 'B', area: 'zx_b', poly: [[0.30, 0.10], [0.55, 0.10], [0.55, 0.35], [0.30, 0.35]] },
|
||||
{ id: 'r3', name: 'C', area: 'zx_c', poly: [[0.10, 0.30], [0.35, 0.30], [0.35, 0.55], [0.10, 0.55]] },
|
||||
...(far ? [{ id: 'r4', name: 'Far', area: 'zx_d', poly: [[90, 90], [91, 90], [91, 91], [90, 91]] }] : []),
|
||||
],
|
||||
}],
|
||||
markers: [{ id: 'm_one', binding: 'device:d_light1', area: 'zx_a' }],
|
||||
settings: { filter_seeded: true },
|
||||
});
|
||||
const ICON_POS = { m_one: { s: 'i1', x: 0.2, y: 0.2 } };
|
||||
|
||||
const iconPx = () => page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
// the badge is --dev-size plus a 1 px border on each side (smoke_icon_scale)
|
||||
return { px: el ? Math.round((el.getBoundingClientRect().width - 2) * 100) / 100 : null,
|
||||
outliers: c._outliers, frameW: Math.round(c._baseVb()[2]) };
|
||||
});
|
||||
|
||||
await load(withFarRoom(false), ICON_POS, 'i1');
|
||||
await raf();
|
||||
const plain = await iconPx();
|
||||
await load(withFarRoom(true), ICON_POS, 'i1');
|
||||
await raf();
|
||||
const strayed = await iconPx();
|
||||
out.strayRoomIsRejectedFromTheFrame = strayed.outliers === 1 && strayed.frameW < 1000;
|
||||
out.strayRoomDoesNotInflateTheIcons =
|
||||
plain.px > 4 && Math.abs(strayed.px - plain.px) < 0.5;
|
||||
if (!out.strayRoomDoesNotInflateTheIcons) console.log('icon px', plain, strayed);
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,147 @@
|
||||
// Опция «Использовать датчик температуры устройства» (marker.use_climate_temp):
|
||||
// климат-устройства (кондиционеры, термостаты) знают температуру комнаты
|
||||
// (attributes.current_temperature). По умолчанию ВЫКЛ — ни плашки, ни вклада
|
||||
// в среднюю. Включена через диалог (реальный клик по чекбоксу): плашка .tval
|
||||
// у значка + голос в средней комнаты (термометр 20 + климат 23.5 → 21.75,
|
||||
// т.е. 21.8 по сетке карточки 0.1°). Unavailable-климат не ломает ничего.
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
// --- впрыскиваем климат-устройства в демо-hass ---------------------------
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const h = window.__mkHass();
|
||||
const inject = (hass) => ({
|
||||
...hass,
|
||||
devices: {
|
||||
...hass.devices,
|
||||
d_ac: { id: 'd_ac', name: 'Air conditioner', model: 'AC-12', area_id: 'living_room', identifiers: [['demo', 'd_ac']], entry_type: null, via_device_id: null },
|
||||
d_trv: { id: 'd_trv', name: 'Radiator valve', model: 'TRV-1', area_id: 'bedroom', identifiers: [['demo', 'd_trv']], entry_type: null, via_device_id: null },
|
||||
},
|
||||
entities: {
|
||||
...hass.entities,
|
||||
'climate.ac': { entity_id: 'climate.ac', device_id: 'd_ac', platform: 'demo' },
|
||||
'climate.trv': { entity_id: 'climate.trv', device_id: 'd_trv', platform: 'demo' },
|
||||
},
|
||||
states: {
|
||||
...hass.states,
|
||||
// термометр гостиной — ровно 20, чтобы средняя с климатом была детерминированной
|
||||
'sensor.living_temp': { ...hass.states['sensor.living_temp'], state: '20' },
|
||||
'climate.ac': { entity_id: 'climate.ac', state: 'cool', attributes: { friendly_name: 'Air conditioner', current_temperature: 23.5, temperature: 24 } },
|
||||
// мёртвый климат: state unavailable, атрибутов нет
|
||||
'climate.trv': { entity_id: 'climate.trv', state: 'unavailable', attributes: { friendly_name: 'Radiator valve' } },
|
||||
},
|
||||
});
|
||||
window.__injectClimate = () => { c.hass = inject(window.__mkHass()); };
|
||||
window.__injectClimate();
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
});
|
||||
await page.waitForFunction(() => window.__card._devices.some((d) => d.entities?.includes('climate.ac')));
|
||||
|
||||
// --- без опции: ни плашки, ни вклада в среднюю ---------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await c.updateComplete;
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
o.acBuilt = !!ac;
|
||||
o.noBadgeByDefault = c._liveTemp(ac) === null;
|
||||
o.avgWithoutOption = c._climate().get('living_room')?.temp; // === 20
|
||||
return o;
|
||||
}), { avgWithoutOption: 20 });
|
||||
|
||||
// --- диалог: чекбокс есть у климат-устройства, нет у обычного, клик мышью --
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// у обычного термометра чекбокса нет
|
||||
const th = c._devices.find((d) => d.entities?.includes('sensor.living_temp'));
|
||||
c._openMarkerDialog(th); await c.updateComplete;
|
||||
o.noCheckboxForThermometer = !sr().querySelector('.dialog .climrow');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// у кондиционера — есть и по умолчанию снят
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
c._openMarkerDialog(ac); await c.updateComplete;
|
||||
const row = sr().querySelector('.dialog .climrow input');
|
||||
o.checkboxForClimate = !!row;
|
||||
o.uncheckedByDefault = !!row && !row.checked;
|
||||
if (row) row.scrollIntoView({ block: 'center' });
|
||||
return o;
|
||||
}));
|
||||
|
||||
// реальный клик по чекбоксу — координатами, через весь стек событий
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const r = sr.querySelector('.dialog .climrow input')?.getBoundingClientRect();
|
||||
return r ? { x: r.x + r.width / 2, y: r.y + r.height / 2 } : null;
|
||||
});
|
||||
check('checkboxClickable', !!box);
|
||||
if (box) await page.mouse.click(box.x, box.y);
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await c.updateComplete;
|
||||
o.clickTicksState = c._markerDialog?.useClimateTemp === true;
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const m = (c._serverCfg.markers || []).find((x) => x.binding === 'device:d_ac');
|
||||
o.savedInMarker = m?.use_climate_temp === true;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- с опцией: плашка «23.5°», средняя пересчиталась ----------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
await c.updateComplete;
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
o.liveTempFromClimate = c._liveTemp(ac); // === 23.5
|
||||
const badges = [...sr().querySelectorAll('.dev .tval')].map((el) => el.textContent.trim());
|
||||
o.badgeVisible = badges.includes('23.5°');
|
||||
// средняя гостиной: (20 + 23.5) / 2 = 21.75 → 21.8 по сетке карточки (0.1°)
|
||||
o.avgWithOption = c._climate().get('living_room')?.temp;
|
||||
// и на КАРТОЧКЕ КОМНАТЫ: включаем метрику температуры и читаем текст
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.settings = { ...(sp.settings || {}), label_temp: true, show_names: true };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const labels = [...sr().querySelectorAll('.roomlabel .rlm')].map((el) => el.textContent.trim());
|
||||
o.roomCardShowsAvg = labels.some((t) => t.includes('21.8°'));
|
||||
return o;
|
||||
}), { liveTempFromClimate: 23.5, avgWithOption: 21.8 });
|
||||
|
||||
// --- unavailable-климат: опция включена, но ни плашки, ни вклада ----------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
c._serverCfg.markers = [...c._serverCfg.markers,
|
||||
{ id: 'd_trv', binding: 'device:d_trv', use_climate_temp: true, hidden: false }];
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const trv = c._devices.find((d) => d.entities?.includes('climate.trv'));
|
||||
o.unavailNoBadge = !!trv && c._liveTemp(trv) === null;
|
||||
o.unavailNoVote = c._climate().get('bedroom')?.temp ?? null; // в спальне нет термометров
|
||||
// а гостиная как была 21.8
|
||||
o.avgStillCorrect = c._climate().get('living_room')?.temp;
|
||||
return o;
|
||||
}), { unavailNoVote: null, avgStillCorrect: 21.8 });
|
||||
|
||||
// --- чекбокс переживает пересоздание диалога ------------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
c._openMarkerDialog(ac); await c.updateComplete;
|
||||
o.survivesReopen = c._markerDialog?.useClimateTemp === true;
|
||||
o.checkboxChecked = sr().querySelector('.dialog .climrow input')?.checked === true;
|
||||
c._markerDialog = null;
|
||||
return o;
|
||||
}));
|
||||
|
||||
checkAll(out, { avgWithoutOption: 20, liveTempFromClimate: 23.5, avgWithOption: 21.8, unavailNoVote: null, avgStillCorrect: 21.8 });
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,67 @@
|
||||
// HP-1454-03: две локальные правки уходили с одной ревизией, вторая терялась.
|
||||
// Debounce разносил только СТАРТЫ. Если первый config/set отвечал дольше 500 мс,
|
||||
// вторая правка уходила с тем же expected_rev, сервер принимал первую и
|
||||
// отклонял вторую как conflict — а обработчик конфликта перечитывал серверную
|
||||
// копию поверх локальной. Правка исчезала, и тост винил «другое окно».
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
const writes = [];
|
||||
let rev = 10;
|
||||
let releaseFirst;
|
||||
const firstGate = new Promise((r) => { releaseFirst = r; });
|
||||
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/set') {
|
||||
const n = writes.length + 1;
|
||||
writes.push({ expected: m.expected_rev, titles: m.config.spaces.map((s) => s.title) });
|
||||
if (n === 1) await firstGate; // первый ответ задержан
|
||||
if (m.expected_rev !== rev) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
|
||||
rev += 1;
|
||||
return { ok: true, rev };
|
||||
}
|
||||
if (m.type === 'houseplan/config/get') {
|
||||
const r = await base(m);
|
||||
return { config: JSON.parse(JSON.stringify(r.config)), rev };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
c._cfgRev = rev;
|
||||
|
||||
// правка №1 и, пока первая запись висит, правка №2
|
||||
c._serverCfg.spaces[0].title = 'FIRST';
|
||||
c._saveConfig();
|
||||
c._saveConfigDebounced.flush();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
out.oneInFlight = writes.length === 1;
|
||||
|
||||
c._serverCfg.spaces[0].title = 'SECOND';
|
||||
c._saveConfig();
|
||||
c._saveConfigDebounced.flush();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
out.stillOneInFlight = writes.length === 1; // вторая ждёт очереди, не летит параллельно
|
||||
|
||||
releaseFirst();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
|
||||
out.writes = writes.length;
|
||||
out.revisions = writes.map((w) => w.expected); // вторая обязана взять новую ревизию
|
||||
out.secondCarriedTheEdit = writes[1]?.titles[0] === 'SECOND';
|
||||
out.editSurvived = c._serverCfg.spaces[0].title === 'SECOND';
|
||||
out.noConflictToast = !(c._toast || '').length;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
oneInFlight: true,
|
||||
stillOneInFlight: true,
|
||||
writes: 2,
|
||||
revisions: [10, 11],
|
||||
secondCarriedTheEdit: true,
|
||||
editSurvived: true,
|
||||
noConflictToast: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,197 @@
|
||||
// A cover NEVER wears a coloured plate (owner's contract 2026-08-04):
|
||||
// «у штор не должно быть жёлтой подложки никогда, индикация открыто/закрыто
|
||||
// за счёт морфинга иконки».
|
||||
//
|
||||
// Before this the domain shared one branch with `valve`: 'open'/'opening' put
|
||||
// the orange «открыто» frame on the badge (.dev.open — a FILLED orange plate,
|
||||
// --hp-open #ff9f43, not just a border), and the travelling states carried it
|
||||
// underneath the breathing ring. Now a cover returns no plate class at all:
|
||||
//
|
||||
// closed / open / ajar -> neutral plate, the ICON tells the state
|
||||
// opening / closing -> neutral plate + the .covermove ring (kept, the
|
||||
// owner approved it 2026-08-03)
|
||||
//
|
||||
// The frame itself is NOT gone: door/window binary sensors and locks still
|
||||
// wear it, and a valve keeps it too (nothing morphs its icon). Those are
|
||||
// re-checked at the bottom, or «no yellow for curtains» would quietly cost
|
||||
// the whole house its «открыто» indication.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// --- colour probes: resolve the tokens the same way the badge does ------
|
||||
const probe = (css) => {
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
|
||||
sr().appendChild(d);
|
||||
const v = getComputedStyle(d).backgroundColor;
|
||||
d.remove();
|
||||
return v;
|
||||
};
|
||||
const YELLOW = probe('var(--hp-on)'); // «включено»
|
||||
const ORANGE = probe('var(--hp-open)'); // «открыто» frame
|
||||
const NEUTRAL = probe('var(--hp-bg)'); // the plain badge
|
||||
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
|
||||
o.yellowIsTheYellow = YELLOW === 'rgb(255, 212, 92)';
|
||||
o.orangeIsTheOpenFrame = ORANGE === 'rgb(255, 159, 67)';
|
||||
|
||||
// --- the gate marker, alone in the Garden, told it IS the curtain -------
|
||||
const setMarker = async () => {
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover' },
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const setCover = async (state, attrs = {}) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'cover.gate': { entity_id: 'cover.gate', state, attributes: { friendly_name: 'Gate', ...attrs } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
// .dev animates `background 0.15s` — a colour read in the same frame is
|
||||
// still mid-fade and would pass on a plate that ends up orange
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
};
|
||||
c._setMode('view');
|
||||
c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await setMarker();
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
|
||||
const devEl = () => sr().querySelector('.devlayer .dev');
|
||||
const cls = () => [...(devEl()?.classList || [])];
|
||||
const plate = () => getComputedStyle(devEl()).backgroundColor;
|
||||
const icon = () => devEl()?.querySelector('ha-icon')?.getAttribute('icon') || '';
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.devlayer .dev').length === 1;
|
||||
|
||||
/** One state: the plate must be the neutral one, and nothing else. */
|
||||
const plateIsNeutral = (tag) => {
|
||||
o[tag + 'PlateNeutral'] = plate() === NEUTRAL;
|
||||
o[tag + 'NotYellow'] = plate() !== YELLOW;
|
||||
o[tag + 'NotOrange'] = plate() !== ORANGE;
|
||||
o[tag + 'NoPlateClass'] = !cls().includes('on') && !cls().includes('open');
|
||||
};
|
||||
|
||||
plateIsNeutral('closed');
|
||||
o.closedNoRing = !cls().includes('covermove');
|
||||
o.closedIconClosed = icon() === 'mdi:curtains-closed';
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
plateIsNeutral('open');
|
||||
o.openNoRing = !cls().includes('covermove');
|
||||
o.openIconOpen = icon() === 'mdi:curtains';
|
||||
|
||||
// ajar: HA reports a positioned curtain as plain 'open' — it reads as OPEN
|
||||
await setCover('open', { device_class: 'curtain', current_position: 40 });
|
||||
plateIsNeutral('ajar');
|
||||
o.ajarIconOpen = icon() === 'mdi:curtains';
|
||||
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
plateIsNeutral('opening');
|
||||
o.openingRings = cls().includes('covermove');
|
||||
o.openingRingIsTheBreathingOne = (() => {
|
||||
const cs = getComputedStyle(devEl(), '::after');
|
||||
return cs.animationName === 'hp-covermove' && cs.animationDuration === '2.2s';
|
||||
})();
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
plateIsNeutral('closing');
|
||||
o.closingRings = cls().includes('covermove');
|
||||
|
||||
// --- the morph covers every class, both ways ---------------------------
|
||||
const morph = async (dc, closedIcon, openIcon) => {
|
||||
await setCover('closed', dc ? { device_class: dc } : {});
|
||||
const a = icon();
|
||||
await setCover('open', dc ? { device_class: dc } : {});
|
||||
const b = icon();
|
||||
return a === closedIcon && b === openIcon && a !== b;
|
||||
};
|
||||
o.morphBlind = await morph('blind', 'mdi:blinds', 'mdi:blinds-open');
|
||||
o.morphShade = await morph('shade', 'mdi:blinds', 'mdi:blinds-open');
|
||||
o.morphShutter = await morph('shutter', 'mdi:window-shutter', 'mdi:window-shutter-open');
|
||||
o.morphCurtain = await morph('curtain', 'mdi:curtains-closed', 'mdi:curtains');
|
||||
o.morphWindow = await morph('window', 'mdi:window-closed', 'mdi:window-open');
|
||||
o.morphAwning = await morph('awning', 'mdi:awning-outline', 'mdi:awning');
|
||||
o.morphDoor = await morph('door', 'mdi:door-closed', 'mdi:door-open');
|
||||
o.morphGarage = await morph('garage', 'mdi:garage', 'mdi:garage-open');
|
||||
o.morphGate = await morph('gate', 'mdi:gate', 'mdi:gate-open');
|
||||
o.morphDamper = await morph('damper', 'mdi:circle-slice-8', 'mdi:circle-outline');
|
||||
// no device_class at all (z2m ships plenty): the gate's auto icon is the
|
||||
// one the name rule «ворота|garage|gate» hands out, and it morphs too
|
||||
o.gateAutoIcon = c._devices.find((x) => x.bindingRef === 'd_gate')?.icon === 'mdi:garage-variant';
|
||||
o.morphNoClass = await morph('', 'mdi:garage-variant', 'mdi:garage-open-variant');
|
||||
// …and an unreadable state morphs nothing, plate still neutral
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
plateIsNeutral('unknown');
|
||||
o.unknownNoMorph = icon() === 'mdi:garage-variant';
|
||||
o.unknownNoRing = !cls().includes('covermove');
|
||||
|
||||
// --- NOT touched: the «открыто» frame elsewhere ------------------------
|
||||
// the demo house has a lock and a window sensor on the ground floor
|
||||
c._space = 'f1';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const elOf = (dev) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(dev);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'lock.front_door': { entity_id: 'lock.front_door', state: 'unlocked', attributes: { friendly_name: 'Front door' } },
|
||||
'binary_sensor.window': { entity_id: 'binary_sensor.window', state: 'on', attributes: { friendly_name: 'Window', device_class: 'window' } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const lockEl = elOf(dev('d_lock'));
|
||||
const winEl = elOf(dev('d_window'));
|
||||
o.lockMarkerFound = !!lockEl;
|
||||
o.windowMarkerFound = !!winEl;
|
||||
o.unlockedLockKeepsTheFrame = !!lockEl && lockEl.classList.contains('open')
|
||||
&& getComputedStyle(lockEl).backgroundColor === ORANGE;
|
||||
o.openWindowKeepsTheFrame = !!winEl && winEl.classList.contains('open')
|
||||
&& getComputedStyle(winEl).backgroundColor === ORANGE;
|
||||
// …and a locked lock is neutral again, so the frame still MEANS something
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'lock.front_door': { entity_id: 'lock.front_door', state: 'locked', attributes: { friendly_name: 'Front door' } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.lockedLockIsNeutral = getComputedStyle(elOf(dev('d_lock'))).backgroundColor === NEUTRAL;
|
||||
|
||||
// a VALVE keeps the frame too: nothing morphs its icon, so the frame is the
|
||||
// only thing it has to say «открыт» with (deliberately left alone)
|
||||
const valve = { id: 'v', primary: 'valve.water', entities: ['valve.water'], marker: null };
|
||||
const clsOfState = (d, states) => {
|
||||
const saved = c.hass;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
|
||||
const r = c._stateClass(d);
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
o.valveOpenKeepsTheFrame = clsOfState(valve, { 'valve.water': { state: 'open', attributes: {} } }) === 'open';
|
||||
o.valveClosedIsNeutral = clsOfState(valve, { 'valve.water': { state: 'closed', attributes: {} } }) === '';
|
||||
// and the cover's own class string, straight from the source
|
||||
const cover = { id: 'k', primary: 'cover.blind', entities: ['cover.blind'], marker: null };
|
||||
o.coverOpenClassIsEmpty = clsOfState(cover, { 'cover.blind': { state: 'open', attributes: { device_class: 'blind' } } }) === '';
|
||||
o.coverOpeningClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'opening', attributes: { device_class: 'blind' } } }) === 'covermove';
|
||||
o.coverClosingClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'closing', attributes: { device_class: 'blind' } } }) === 'covermove';
|
||||
o.coverUnavailableStillFades = clsOfState(cover, { 'cover.blind': { state: 'unavailable', attributes: {} } }) === 'unavail';
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,234 @@
|
||||
// «Открыть/закрыть» on a curtain whose cover entity is NOT the primary one
|
||||
// (owner's report 2026-08-04: «в настройках "открыть\закрыть", а по нажатию
|
||||
// по-прежнему инфо-карточка»).
|
||||
//
|
||||
// The device below is his, entity for entity: an Aqara «Roller shade driver
|
||||
// E1» ships the `cover.*` HIDDEN by the integration and a perfectly visible
|
||||
// `switch.*_reverse_direction` beside it. `primaryEntity` ranks visible above
|
||||
// hidden and `switch` above `cover`, so the marker's primary was the service
|
||||
// switch — the tap then resolved on the domain `switch` and the action the
|
||||
// user had explicitly chosen degraded to the info card, while the dialog kept
|
||||
// OFFERING the action, because that check already looked at every entity of
|
||||
// the device. The tap now finds the cover the same way the climate
|
||||
// temperature finds the thermostat: among all of them.
|
||||
//
|
||||
// End to end: the action is chosen in the dialog and SAVED through the normal
|
||||
// path, and the click is done on the marker the card rebuilt from that config.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
|
||||
// ---- the owner's curtain driver, joined to the demo house --------------
|
||||
const ENTS = {
|
||||
// hidden by the integration — exactly as z2m ships it
|
||||
'cover.office_curtain': { entity_id: 'cover.office_curtain', device_id: 'd_curtain', platform: 'demo', hidden: true },
|
||||
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', device_id: 'd_curtain', platform: 'demo', entity_category: 'diagnostic' },
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', device_id: 'd_curtain', platform: 'demo' },
|
||||
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', device_id: 'd_curtain', platform: 'demo' },
|
||||
// a MIXED device: a lamp that also happens to ship a cover. Its primary is
|
||||
// meaningfully the light, and nothing below may take that away from it.
|
||||
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
};
|
||||
const coverState = (state, attrs = {}) => ({
|
||||
entity_id: 'cover.office_curtain', state,
|
||||
attributes: { friendly_name: 'Office curtain', ...attrs },
|
||||
});
|
||||
const push = async (state, attrs = {}) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
devices: { ...c.hass.devices,
|
||||
d_curtain: { id: 'd_curtain', name: 'Office curtain', model: 'Roller shade driver E1',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_curtain']], entry_type: null, via_device_id: null },
|
||||
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null } },
|
||||
entities: { ...c.hass.entities, ...ENTS },
|
||||
states: { ...c.hass.states,
|
||||
'cover.office_curtain': coverState(state, attrs),
|
||||
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', state: '84', attributes: { device_class: 'battery', unit_of_measurement: '%' } },
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state: 'off', attributes: { friendly_name: 'Reverse direction' } },
|
||||
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', state: 'stopped', attributes: {} },
|
||||
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', state: 'opening', attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
|
||||
},
|
||||
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
// park both extra markers in their own corners: the DOM assertions below
|
||||
// find a marker by its position, and the auto grid would sit them on top
|
||||
// of the demo house's own bedroom devices
|
||||
const cu = c._devices.find((x) => x.bindingRef === 'd_curtain');
|
||||
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
if (cu && mx) {
|
||||
c._layout = { ...c._layout,
|
||||
[cu.id]: { s: cu.space, x: 0.06, y: 0.94 },
|
||||
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 } };
|
||||
}
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
const curtain = () => c._devices.find((x) => x.bindingRef === 'd_curtain');
|
||||
const mixed = () => c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
o.deviceIsOnThePlan = !!curtain();
|
||||
// the premise of the whole report: the cover is NOT the primary entity
|
||||
o.primaryIsNotTheCover = curtain()?.primary === 'switch.office_curtain_reverse';
|
||||
o.coverIsAmongTheEntities = (curtain()?.entities || []).includes('cover.office_curtain');
|
||||
|
||||
// ---- the dialog offers the action and SAVES it -------------------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(curtain()); await c.updateComplete;
|
||||
const optionValues = () => [...sr().querySelectorAll('.dialog option')].map((e) => e.value);
|
||||
o.dialogOffersCover = optionValues().includes('cover');
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' };
|
||||
await c.updateComplete;
|
||||
await c._saveMarker();
|
||||
await c.updateComplete;
|
||||
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
|
||||
o.markerSaved = saved?.tap_action === 'cover';
|
||||
|
||||
// ---- and the tap on THAT marker opens the curtain -----------------------
|
||||
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const tap = async () => {
|
||||
c._clickDevice({ stopPropagation() {} }, curtain());
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
};
|
||||
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'cover';
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.closedOpensTheCover = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
o.noInfoCard = !c._infoCard;
|
||||
c._infoCard = null;
|
||||
|
||||
await push('open', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.openClosesTheCover = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'close_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.travellingStops = (calls[calls.length - 1] || [])[1] === 'stop_cover';
|
||||
|
||||
// the service switch, which used to be the whole story, is never touched
|
||||
o.serviceSwitchNeverCalled = !calls.some((x) => String(x[2]?.entity_id).startsWith('switch.'));
|
||||
|
||||
// ---- the guarded classes still degrade, read off the COVER --------------
|
||||
await push('closed', { device_class: 'garage' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.garageCallsNothing = calls.length === 0;
|
||||
o.garageShowsInfo = !!c._infoCard;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(curtain()); await c.updateComplete;
|
||||
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
|
||||
c._markerDialog = null;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
|
||||
// ---- and the marker SHOWS that cover, not the service switch -----------
|
||||
// Owner, 2026-08-04: «нет ни дышащего кольца во время хода, ни рамки
|
||||
// "открыто", ни морфинга иконки». (The «открыто» frame itself was retired
|
||||
// for covers later the same day — the marker still had to start speaking for
|
||||
// the cover, which is what this section pins.) Same cause, same helper:
|
||||
// _stateClass and
|
||||
// the icon morph read d.primary, so the plan reported the state of
|
||||
// `switch.*_reverse_direction`. The rule (docs/FILTERING.md «What a marker
|
||||
// SHOWS»): the marker indicates the entity its tap ACTS ON — the cover
|
||||
// exactly when the owner has explicitly chosen «Открыть/закрыть».
|
||||
const devEl = (dev) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(dev);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const clsOf = (dev) => [...(devEl(dev)?.classList || [])];
|
||||
const iconOf = (dev) => devEl(dev)?.querySelector('ha-icon')?.getAttribute('icon') || '';
|
||||
const setSwitch = async (state) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state, attributes: {} } } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
};
|
||||
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
o.markerIsOnScreen = !!devEl(curtain());
|
||||
o.closedIsNeitherOpenNorMoving =
|
||||
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('covermove');
|
||||
o.closedIconIsClosedCurtains = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
|
||||
await push('open', { device_class: 'curtain' });
|
||||
// the plate is neutral in every cover state since 2026-08-04 — the morph is
|
||||
// the whole open/closed story (smoke_cover_no_plate.mjs)
|
||||
o.openWearsNoColouredPlate =
|
||||
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('on');
|
||||
o.openIconIsOpenCurtains = iconOf(curtain()) === 'mdi:curtains';
|
||||
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
o.travellingBreathes = clsOf(curtain()).includes('covermove');
|
||||
await push('closing', { device_class: 'curtain' });
|
||||
o.closingBreathesToo = clsOf(curtain()).includes('covermove');
|
||||
o.stateClassIsTheSameStory = c._stateClass(curtain()).includes('covermove');
|
||||
|
||||
// the service switch never speaks for the marker again: reverse-direction
|
||||
// ON used to paint the curtain yellow («включено») while it stood still
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('on');
|
||||
o.reverseSwitchNeverLightsTheMarker = !clsOf(curtain()).includes('on');
|
||||
|
||||
// THE BOUNDARY of the rule: take the explicit action away and the marker
|
||||
// goes back to its primary entity — no cover indication is invented
|
||||
const savedMarker = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
|
||||
savedMarker.tap_action = 'info';
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
await setSwitch('on');
|
||||
o.withoutTheActionThePrimarySpeaks = clsOf(curtain()).includes('on')
|
||||
&& !clsOf(curtain()).includes('covermove');
|
||||
savedMarker.tap_action = 'cover';
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('off');
|
||||
o.actionBackMeansCoverBack = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
|
||||
// ---- the auditor's own probe: BOTH entities visible ---------------------
|
||||
// DEV-2C947-04 was written up with a plain visible `cover.*` beside a
|
||||
// visible `switch.*`: no hidden tier involved, `switch` simply outranks
|
||||
// `cover` inside DOMAIN_PRIORITY. Same premise, same cure — pinned here so
|
||||
// the finding cannot come back through the other door.
|
||||
ENTS['cover.office_curtain'] = { ...ENTS['cover.office_curtain'], hidden: false };
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('off');
|
||||
o.visibleCoverIsStillNotThePrimary = curtain()?.primary === 'switch.office_curtain_reverse';
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.visibleCoverOpensOnTap = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
o.visibleCoverAlsoIndicates = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
o.noInfoCardForTheVisibleCover = !c._infoCard;
|
||||
|
||||
// ---- a MIXED device is not hijacked ------------------------------------
|
||||
// A lit lamp that also owns a cover keeps the lamp: no explicit «Open/close»
|
||||
// on this marker, so nothing changes for it — and even if there were one,
|
||||
// a shining light still wins the yellow plate (the glow and the badge may
|
||||
// never disagree, owner's principle 2026-07-29).
|
||||
o.mixedPrimaryIsTheLight = mixed()?.primary === 'light.mixed';
|
||||
o.mixedCoverIsTravelling = c.hass.states['cover.mixed'].state === 'opening';
|
||||
o.mixedStaysALitLamp = clsOf(mixed()).includes('on') && !clsOf(mixed()).includes('covermove');
|
||||
// its icon is the device's own auto icon, NOT the cover's open/closed pair
|
||||
o.mixedIconIsNotMorphedByTheCover =
|
||||
iconOf(mixed()) === mixed()?.icon && !iconOf(mixed()).includes('curtains');
|
||||
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,207 @@
|
||||
// An EXPLICIT «Открыть/закрыть» marker is a curtain, and a curtain is never
|
||||
// painted — audit DEV-1DA1-01 (P2).
|
||||
//
|
||||
// `_stateClass` used to answer in this order: bound `controls` first, then any
|
||||
// lit `light.*` of the device, and only then the entity the tap acts on
|
||||
// (`_actEntity` → `_coverIndicator`). So the two combinations below still made
|
||||
// a curtain yellow, in flat contradiction with the owner's contract («у штор
|
||||
// не должно быть жёлтой подложки НИКОГДА») and with the promise that a marker
|
||||
// indicates the entity its tap drives:
|
||||
//
|
||||
// 1. a MIXED device (a lamp that also ships a blind) told `tap_action:
|
||||
// 'cover'` — the lit `light.mixed` returned 'on' before the cover branch
|
||||
// was ever reached, so the travelling blind lost its breathing ring too;
|
||||
// 2. a curtain marker with a bound `controls` target that happened to be on
|
||||
// — `controls` returned 'on' first, same result.
|
||||
//
|
||||
// The order now starts with the explicit cover: saying «this thing is a
|
||||
// curtain» in the dialog is the strongest statement the card has about what a
|
||||
// marker IS, so nothing may paint over it. Everything else keeps the old
|
||||
// precedence — a marker that was NOT told it is a curtain is untouched, and
|
||||
// that is re-checked at the bottom.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// ---- the plate colours, resolved the way the badge resolves them -------
|
||||
const probe = (css) => {
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
|
||||
sr().appendChild(d);
|
||||
const v = getComputedStyle(d).backgroundColor;
|
||||
d.remove();
|
||||
return v;
|
||||
};
|
||||
const YELLOW = probe('var(--hp-on)');
|
||||
const ORANGE = probe('var(--hp-open)');
|
||||
const NEUTRAL = probe('var(--hp-bg)');
|
||||
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
|
||||
|
||||
// =======================================================================
|
||||
// Part 1 — the class itself, straight from _stateClass, over every state
|
||||
// =======================================================================
|
||||
const withStates = (states, fn) => {
|
||||
const saved = c.hass;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
|
||||
const r = fn();
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
const st = (state, attrs = {}) => ({ state, attributes: attrs });
|
||||
/** The auditor's marker #1: primary is a LIT light, the cover is explicit. */
|
||||
const litLamp = {
|
||||
id: 'p_lamp', primary: 'light.mixed', entities: ['light.mixed', 'cover.mixed'],
|
||||
tapAction: 'cover', marker: null,
|
||||
};
|
||||
/** The auditor's marker #2: a cover marker whose bound control is on. */
|
||||
const controlled = {
|
||||
id: 'p_ctrl', primary: 'cover.mixed', entities: ['cover.mixed'],
|
||||
tapAction: 'cover', marker: { id: 'p_ctrl', binding: 'device:x', controls: ['switch.control'] },
|
||||
};
|
||||
const CV = { closed: '', open: '', opening: 'covermove', closing: 'covermove' };
|
||||
for (const [state, want] of Object.entries(CV)) {
|
||||
const states = {
|
||||
'light.mixed': st('on', { friendly_name: 'Bedside lamp' }),
|
||||
'switch.control': st('on', {}),
|
||||
'cover.mixed': st(state, { device_class: 'curtain' }),
|
||||
};
|
||||
o['litLamp_' + state] = withStates(states, () => c._stateClass(litLamp)) === want;
|
||||
o['controlled_' + state] = withStates(states, () => c._stateClass(controlled)) === want;
|
||||
}
|
||||
// an unreachable cover still only fades — never yellow, never orange
|
||||
o.litLampUnavailFades = withStates({
|
||||
'light.mixed': st('on', {}), 'cover.mixed': st('unavailable', {}),
|
||||
}, () => c._stateClass(litLamp)) === 'unavail';
|
||||
o.controlledUnavailFades = withStates({
|
||||
'switch.control': st('on', {}), 'cover.mixed': st('unavailable', {}),
|
||||
}, () => c._stateClass(controlled)) === 'unavail';
|
||||
// …and a cover entity with no state at all is simply neutral, not yellow
|
||||
o.litLampNoCoverStateIsNeutral = withStates({
|
||||
'light.mixed': st('on', {}),
|
||||
}, () => c._stateClass(litLamp)) === '';
|
||||
|
||||
// ---- the OTHER side of the rule: nothing changed for the rest ----------
|
||||
// the very same mixed device WITHOUT the explicit action keeps its primary
|
||||
const plainLamp = { ...litLamp, id: 'p_plain', tapAction: null };
|
||||
o.mixedWithoutTheOptionStaysYellow = withStates({
|
||||
'light.mixed': st('on', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(plainLamp)) === 'on';
|
||||
o.mixedWithoutTheOptionOffIsNeutral = withStates({
|
||||
'light.mixed': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(plainLamp)) === '';
|
||||
// a wall switch with controls and NO cover in sight still mirrors them
|
||||
const remote = {
|
||||
id: 'p_remote', primary: 'sensor.remote', entities: ['sensor.remote'], tapAction: null,
|
||||
marker: { id: 'p_remote', binding: 'device:y', controls: ['light.hall'] },
|
||||
};
|
||||
o.remoteMirrorsItsControls = withStates({ 'light.hall': st('on', {}) },
|
||||
() => c._stateClass(remote)) === 'on';
|
||||
o.remoteOffIsNeutral = withStates({ 'light.hall': st('off', {}) },
|
||||
() => c._stateClass(remote)) === '';
|
||||
// a curtain marker with controls that are OFF is still just a curtain
|
||||
o.controlledOffStillRings = withStates({
|
||||
'switch.control': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(controlled)) === 'covermove';
|
||||
// an explicit «cover» marker with NO cover among its entities falls back to
|
||||
// the old order — the statement is only as strong as the entity behind it
|
||||
const noCover = {
|
||||
id: 'p_nocover', primary: 'light.mixed', entities: ['light.mixed'], tapAction: 'cover', marker: null,
|
||||
};
|
||||
o.explicitCoverWithoutACoverKeepsThePrimary = withStates({ 'light.mixed': st('on', {}) },
|
||||
() => c._stateClass(noCover)) === 'on';
|
||||
|
||||
// =======================================================================
|
||||
// Part 2 — the real markers on the real plan: the PLATE, not just a class
|
||||
// =======================================================================
|
||||
const ENTS = {
|
||||
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.ctrl': { entity_id: 'cover.ctrl', device_id: 'd_ctrl', platform: 'demo' },
|
||||
'switch.control': { entity_id: 'switch.control', device_id: 'd_wall', platform: 'demo' },
|
||||
};
|
||||
const push = async (coverState) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
devices: { ...c.hass.devices,
|
||||
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null },
|
||||
d_ctrl: { id: 'd_ctrl', name: 'Office curtain', model: 'Curtain motor',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_ctrl']], entry_type: null, via_device_id: null },
|
||||
d_wall: { id: 'd_wall', name: 'Wall switch', model: 'Wall switch',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_wall']], entry_type: null, via_device_id: null } },
|
||||
entities: { ...c.hass.entities, ...ENTS },
|
||||
states: { ...c.hass.states,
|
||||
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', state: coverState, attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
|
||||
'cover.ctrl': { entity_id: 'cover.ctrl', state: coverState, attributes: { friendly_name: 'Office curtain', device_class: 'curtain' } },
|
||||
'switch.control': { entity_id: 'switch.control', state: 'on', attributes: { friendly_name: 'Wall switch' } },
|
||||
},
|
||||
};
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_mixed', binding: 'device:d_mixed', tap_action: 'cover' },
|
||||
{ id: 'm_ctrl', binding: 'device:d_ctrl', tap_action: 'cover', controls: ['switch.control'] },
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
const ct = c._devices.find((x) => x.bindingRef === 'd_ctrl');
|
||||
if (mx && ct) {
|
||||
c._layout = { ...c._layout,
|
||||
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 },
|
||||
[ct.id]: { s: ct.space, x: 0.06, y: 0.94 } };
|
||||
}
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
// .dev animates `background 0.15s` — read a settled colour, not a fade
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
};
|
||||
c._setMode('view');
|
||||
c._space = 'f1';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await push('opening');
|
||||
|
||||
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
|
||||
const elOf = (d) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(d);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const report = (tag, ref) => {
|
||||
const d = dev(ref);
|
||||
const el = d && elOf(d);
|
||||
o[tag + 'MarkerFound'] = !!el;
|
||||
if (!el) return;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
o[tag + 'PlateNeutral'] = bg === NEUTRAL;
|
||||
o[tag + 'NotYellow'] = bg !== YELLOW;
|
||||
o[tag + 'NotOrange'] = bg !== ORANGE;
|
||||
o[tag + 'NoOnClass'] = !el.classList.contains('on') && !el.classList.contains('open');
|
||||
o[tag + 'Rings'] = el.classList.contains('covermove');
|
||||
o[tag + 'IconIsTheCover'] = el.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:curtains';
|
||||
};
|
||||
o.lampIsTrulyLit = c.hass.states['light.mixed'].state === 'on';
|
||||
o.controlIsTrulyOn = c.hass.states['switch.control'].state === 'on';
|
||||
report('litLampDom', 'd_mixed');
|
||||
report('controlledDom', 'd_ctrl');
|
||||
|
||||
// …and at rest (closed) they are plain, with no ring
|
||||
await push('closed');
|
||||
const closedBg = (ref) => getComputedStyle(elOf(dev(ref))).backgroundColor;
|
||||
o.litLampClosedNeutral = closedBg('d_mixed') === NEUTRAL;
|
||||
o.controlledClosedNeutral = closedBg('d_ctrl') === NEUTRAL;
|
||||
o.litLampClosedNoRing = !elOf(dev('d_mixed')).classList.contains('covermove');
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,196 @@
|
||||
// Covers: the «Open/close» tap action + the travelling indication
|
||||
// (owner's contract 2026-08-03).
|
||||
//
|
||||
// closed -> cover.open_cover
|
||||
// open / ajar -> cover.close_cover
|
||||
// opening / closing -> cover.stop_cover (a tap during travel is a STOP;
|
||||
// the next tap simply travels the other way)
|
||||
// unknown -> cover.toggle, no morphing, no pulse
|
||||
//
|
||||
// The option is offered ONLY for a binding that HAS a cover entity, and never
|
||||
// for the guarded classes (garage/door/gate) — for those a value smuggled into
|
||||
// the config degrades to 'info', exactly like a card-wide toggle does.
|
||||
// While travelling the icon breathes a soft ring (.covermove, the vacuum
|
||||
// puck's 2.2s period) and the plate stays NEUTRAL — yellow means «включено».
|
||||
// Since 2026-08-04 the plate is neutral in EVERY cover state, the «открыто»
|
||||
// frame included: open/closed is told by the icon morph alone (the full
|
||||
// contract lives in smoke_cover_no_plate.mjs).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
const YELLOW = 'rgb(255, 212, 92)'; // --hp-on, the «включено» plate
|
||||
|
||||
const gateEntity = (state, attrs) => ({
|
||||
entity_id: 'cover.gate', state,
|
||||
attributes: { friendly_name: 'Gate', ...attrs },
|
||||
});
|
||||
/** Push a cover state into hass keeping the service recorder in place. */
|
||||
const setCover = async (state, attrs = {}) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
states: { ...c.hass.states, 'cover.gate': gateEntity(state, attrs) },
|
||||
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
|
||||
};
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
/** Marker for the gate device: tap action + optional confirmation.
|
||||
* `solo` also hides the mower, leaving exactly one badge in the Garden —
|
||||
* the indication checks then read the cover's own plate, not a neighbour's. */
|
||||
const setMarker = async (tapAction, confirm = false, solo = false) => {
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: tapAction, tap_confirm: confirm || null },
|
||||
...(solo ? [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] : []),
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const gate = () => c._devices.find((x) => x.bindingRef === 'd_gate');
|
||||
const tap = async () => { c._clickDevice({ stopPropagation() {} }, gate()); await c.updateComplete; };
|
||||
// never throw when the feature is missing: the smoke must REPORT the gap
|
||||
const lastCall = () => calls[calls.length - 1] || ['-', '-', {}];
|
||||
// the gate is the only cover in the Garden space; the plan draws no names
|
||||
// inside the badge, so the icon set of the space is the readable handle
|
||||
const devEl = () => sr().querySelector('.dev');
|
||||
const icons = () => [...sr().querySelectorAll('.dev ha-icon')].map((e) => e.getAttribute('icon'));
|
||||
|
||||
// the gate lives in the Garden space — look at it, not at the ground floor
|
||||
c._setMode('view');
|
||||
c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
|
||||
// ---- the dialog offers the option only where it makes sense ------------
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const optionValues = () => [...sr().querySelectorAll('.dialog option')].map((e) => e.value);
|
||||
c._openMarkerDialog(gate()); await c.updateComplete;
|
||||
o.dialogOffersCoverForCurtain = optionValues().includes('cover');
|
||||
// …and the confirmation checkbox follows the choice, like it does for run/toggle
|
||||
const rowsBefore = sr().querySelectorAll('.dialog .srcrow').length;
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' }; await c.updateComplete;
|
||||
o.confirmCheckboxShownForCover = sr().querySelectorAll('.dialog .srcrow').length > rowsBefore;
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
|
||||
// a GARAGE door never gets the option (owner: «нет, только шторы/жалюзи»)
|
||||
await setCover('closed', { device_class: 'garage' });
|
||||
c._openMarkerDialog(gate()); await c.updateComplete;
|
||||
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// …and neither does a device without a cover entity at all
|
||||
c._openMarkerDialog(c._devices.find((x) => x.bindingRef === 'd_mower')); await c.updateComplete;
|
||||
o.dialogHidesCoverWithoutCover = !optionValues().includes('cover');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
c._setMode('view'); c._space = 'garden'; c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// ---- the tap itself ----------------------------------------------------
|
||||
await setMarker('cover');
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.closedOpens = JSON.stringify(lastCall()) === JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.gate' }]);
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.openCloses = lastCall()[0] === 'cover' && lastCall()[1] === 'close_cover';
|
||||
|
||||
// a half-drawn curtain is still 'open' for HA — it closes
|
||||
await setCover('open', { device_class: 'curtain', current_position: 40 });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.ajarCloses = lastCall()[1] === 'close_cover';
|
||||
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.openingStops = lastCall()[1] === 'stop_cover';
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.closingStops = lastCall()[1] === 'stop_cover';
|
||||
|
||||
// no readable state — a plain cover.toggle, nothing clever
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.unknownToggles = lastCall()[0] === 'cover' && lastCall()[1] === 'toggle';
|
||||
|
||||
// ---- the confirmation checkbox works for this action too ---------------
|
||||
await setMarker('cover', true);
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.confirmAsks = !!c._tapConfirm && c._tapConfirm.text.includes('Gate');
|
||||
c._tapConfirm = null; await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.confirmCancelCallsNothing = calls.length === 0;
|
||||
await tap();
|
||||
const conf = c._tapConfirm; c._tapConfirm = null; conf?.exec?.();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.confirmOkOpens = calls.length === 1 && calls[0][1] === 'open_cover';
|
||||
|
||||
// ---- a saved 'cover' on a GARAGE degrades to the info card -------------
|
||||
await setMarker('cover');
|
||||
await setCover('closed', { device_class: 'garage' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.garageSavedCoverCallsNothing = calls.length === 0;
|
||||
o.garageSavedCoverShowsInfo = !!c._infoCard;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
|
||||
// ---- the indication ----------------------------------------------------
|
||||
const afterAnim = () => {
|
||||
const el = devEl();
|
||||
if (!el) return null;
|
||||
const cs = getComputedStyle(el, '::after');
|
||||
return { name: cs.animationName, dur: cs.animationDuration };
|
||||
};
|
||||
await setMarker('cover', false, true); // the gate alone on the plan
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
// .dev animates `background 0.15s`: lit recycles the badge node the hidden
|
||||
// mower used, so a plate colour read in the same frame is still mid-fade
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.dev').length === 1;
|
||||
o.pulseWhileOpening = sr().querySelectorAll('.dev.covermove').length === 1;
|
||||
const a1 = afterAnim();
|
||||
o.pulseIsBreathingRing = a1?.name === 'hp-covermove' && a1?.dur === '2.2s';
|
||||
// the plate stays NEUTRAL while travelling: yellow is reserved for «включено»
|
||||
o.movingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.pulseWhileClosing = sr().querySelectorAll('.dev.covermove').length === 1;
|
||||
o.closingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
o.noPulseWhenOpen = sr().querySelectorAll('.dev.covermove').length === 0;
|
||||
o.iconMorphOpen = icons().includes('mdi:curtains') && !icons().includes('mdi:curtains-closed');
|
||||
// owner 2026-08-04: never a coloured plate on a cover — not even «открыто»
|
||||
o.openWearsNoFrame = sr().querySelectorAll('.dev.open').length === 0;
|
||||
o.openPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
o.noPulseWhenClosed = sr().querySelectorAll('.dev.covermove').length === 0;
|
||||
o.iconMorphClosed = icons().includes('mdi:curtains-closed') && !icons().includes('mdi:curtains');
|
||||
|
||||
await setCover('open', { device_class: 'shutter' });
|
||||
o.iconMorphShutterOpen = icons().includes('mdi:window-shutter-open');
|
||||
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
o.noPulseWhenUnknown = sr().querySelectorAll('.dev.covermove').length === 0;
|
||||
o.noMorphWhenUnknown = icons().includes(gate().icon)
|
||||
&& !icons().some((i) => i.startsWith('mdi:curtains'));
|
||||
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -25,10 +25,22 @@ const res = await page.evaluate(async () => {
|
||||
out.rectRendered = !!sr().querySelector('.decorlayer rect.dshape');
|
||||
// 3) линия и овал
|
||||
c._decorDraft = { kind: 'line', a: [g * 2, g * 2], b: [g * 8, g * 2], pid: 1 };
|
||||
await c.updateComplete;
|
||||
// превью рисуемой линии уже с круглыми концами (зубцы на стыках)
|
||||
const draftLine = sr().querySelector('.decorlayer line.ddraft');
|
||||
out.draftLineRound = !!draftLine
|
||||
&& draftLine.getAttribute('stroke-linecap') === 'round'
|
||||
&& draftLine.getAttribute('stroke-linejoin') === 'round';
|
||||
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;
|
||||
// сохранённые линии рендерятся с круглыми концами: диаметр = толщине линии,
|
||||
// стык двух линий под углом — без зубца (скриншот владельца)
|
||||
await c.updateComplete;
|
||||
const lines = [...sr().querySelectorAll('.decorlayer line.dshape')];
|
||||
out.savedLineRound = lines.length > 0 && lines.every((l) =>
|
||||
l.getAttribute('stroke-linecap') === 'round' && l.getAttribute('stroke-linejoin') === 'round');
|
||||
// вырожденная фигура не сохраняется
|
||||
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
|
||||
c._decorCommitDraft();
|
||||
|
||||
@@ -11,7 +11,7 @@ const res = await page.evaluate(async () => {
|
||||
document.body.appendChild(c1);
|
||||
c1.hass = { language:'en', locale:{language:'en'}, devices:{}, entities:{}, areas:{}, states:{},
|
||||
callWS: async (m) => m.type==='houseplan/config/get'
|
||||
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, aspect:1.4, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
: { layout:{} },
|
||||
connection:{ subscribeEvents: async()=>()=>{} } };
|
||||
await new Promise(r=>setTimeout(r,150));
|
||||
@@ -31,7 +31,7 @@ const res = await page.evaluate(async () => {
|
||||
devices:{ d1:{ id:'d1', name: evil, model:'M<script>1</script>', area_id:'a1', identifiers:[['x','1']] } },
|
||||
entities:{}, areas:{ a1:{ area_id:'a1', name:'A1' } }, states:{},
|
||||
callWS: async (m) => m.type==='houseplan/config/get'
|
||||
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, aspect:1, view_box:[0,0,1,1],
|
||||
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, view_box:[0,0,1,1],
|
||||
rooms:[{ id:'r1', name: evil, area:'a1', poly:[[0.1,0.1],[0.9,0.1],[0.9,0.9],[0.1,0.9]] }], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
: { layout:{} },
|
||||
connection:{ subscribeEvents: async()=>()=>{} } };
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// На телефоне в редакторах не работали зум и навигация жестами: pointerdown
|
||||
// сцены выходил сразу при _markup, так что пинч и пан не начинались вовсе.
|
||||
// Рисование кликается, жесты двигаются — они совместимы; палец с движением
|
||||
// панорамирует, два пальца зумируют, отпускание после жеста не рисует точку.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
const pd = (c, id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (c, id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (c, id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
|
||||
// --- пинч-зум в редакторе плана -----------------------------------------
|
||||
out.pinchZoomsInPlanEditor = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._setMode('plan');
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
c._resetZoom();
|
||||
const z0 = c._zoom;
|
||||
pd(1, 300, 300); pd(2, 400, 300); // два пальца
|
||||
pm(1, 250, 300); pm(2, 450, 300); // разводим
|
||||
const zoomed = c._zoom > z0 * 1.5;
|
||||
pu(1, 250, 300); pu(2, 450, 300);
|
||||
return zoomed && c._path.length === 0;
|
||||
});
|
||||
|
||||
// --- пан одним пальцем в редакторе, точка не рисуется --------------------
|
||||
out.panWorksAndDoesNotDraw = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
const st = c._stageEl;
|
||||
c._zoomAt(st.clientWidth / 2, st.clientHeight / 2, 3); // есть куда панорамировать
|
||||
const v0 = { ...c._view };
|
||||
pd(3, 300, 300);
|
||||
pm(3, 380, 340); pm(3, 420, 360);
|
||||
const panned = Math.abs(c._view.x - v0.x) > 1 || Math.abs(c._view.y - v0.y) > 1;
|
||||
const suppressed = c._suppressClick === true;
|
||||
c._markupClick({ composedPath: () => [], clientX: 420, clientY: 360 }); // синтезированный click после пана
|
||||
const noDot = c._path.length === 0;
|
||||
pu(3, 420, 360);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
return panned && suppressed && noDot;
|
||||
});
|
||||
|
||||
// --- обычный клик без движения по-прежнему рисует ------------------------
|
||||
out.tapStillDraws = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
c._resetZoom();
|
||||
c._tool = 'draw';
|
||||
const before = c._path.length;
|
||||
pd(4, 300, 300); pu(4, 300, 300);
|
||||
const st = c._stageEl.getBoundingClientRect();
|
||||
c._markupClick({ composedPath: () => [], clientX: st.left + 200, clientY: st.top + 200 });
|
||||
const drew = c._path.length > before;
|
||||
c._path = []; c._setMode('view');
|
||||
return drew;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -9,10 +9,13 @@ const res = await page.evaluate(async () => {
|
||||
const btn = sr().querySelector('.rlgearbtn');
|
||||
out.gearButtonShown = !!btn;
|
||||
const cs = btn ? getComputedStyle(btn) : null;
|
||||
out.gearReadable = cs ? parseFloat(cs.fontSize) >= 10 && cs.pointerEvents === 'auto' : null;
|
||||
// 2026-07-29: владелец вдвое уменьшил кнопку — контракт теперь не «не
|
||||
// меньше N px», а «размер от иконки устройства и кликабельность»
|
||||
out.gearReadable = cs ? parseFloat(cs.fontSize) > 0 && cs.pointerEvents === 'auto' : null;
|
||||
out.gearHasLabel = btn ? btn.textContent.trim().length > 0 : null;
|
||||
const box = btn?.getBoundingClientRect();
|
||||
out.gearTapTarget = box ? box.height >= 18 && box.width >= 40 : null;
|
||||
// (в редакторе плана .dev скрыты display:none — сравнивать не с чем)
|
||||
out.gearTapTarget = box ? box.height > 6 && box.height < 40 : null;
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
out.gearOpensDialog = c._roomDialog === true && !!c._roomEditId;
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// 1) диалог общих настроек: 7 цветовых строк, 3 группы
|
||||
// 1) диалог общих настроек: инвентарь строк и групп (обновлён фичей «Солнце»)
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
out.rows = sr().querySelectorAll('.gsrow').length;
|
||||
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
|
||||
// 1b) блок About: версия + две внешние ссылки (target=_blank rel=noopener)
|
||||
out.aboutVersion = sr().querySelector('.dialog .aboutver')?.textContent.trim() ?? null;
|
||||
out.aboutLinks = [...sr().querySelectorAll('.dialog a.aboutlink')].map((a) => ({
|
||||
href: a.getAttribute('href'), target: a.getAttribute('target'), rel: a.getAttribute('rel') }));
|
||||
// 2) сменить цвет light_on и сохранить
|
||||
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
|
||||
await c._saveSettingsDialog(); await c.updateComplete;
|
||||
@@ -26,10 +31,22 @@ const res = await page.evaluate(async () => {
|
||||
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
|
||||
return out;
|
||||
});
|
||||
// CARD_VERSION из собранного бандла (тот же текст уходит в console-баннер).
|
||||
// terser либо инлайнит строку (v1.56.0), либо оставляет переменную (v${xx}) —
|
||||
// во втором случае доразрешаем её по присваиванию xx="1.56.0".
|
||||
const bundle = readFileSync(new URL('./srv/assets/houseplan-card.js', import.meta.url), 'utf8');
|
||||
const m = bundle.match(/HOUSEPLAN-CARD %c v(?:(\d+\.\d+\.\d+)|\$\{(\w+)\})/);
|
||||
const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[2]}="(\\d+\\.\\d+\\.\\d+)"`))?.[1]);
|
||||
check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION.length > 0);
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"rows": 11,
|
||||
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill"],
|
||||
"rows": 14, // 10 цветов + радиус свечения + режим фона + цвет фона + погода (docs/SUN.md)
|
||||
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background", "Sun", "About"],
|
||||
"aboutVersion": `Houseplan Card v${BUNDLE_VERSION}`, // та же константа, что в баннере
|
||||
"aboutLinks": [
|
||||
{ "href": "https://github.com/Matysh/houseplan-card", "target": "_blank", "rel": "noopener" },
|
||||
{ "href": "https://t.me/ha_houseplan", "target": "_blank", "rel": "noopener" },
|
||||
],
|
||||
"saved": {"c": "#ff00ff", "a": 0.5},
|
||||
"lqiBefore": 7,
|
||||
"lqiAfter": 0,
|
||||
|
||||
+3
-4
@@ -44,7 +44,7 @@ const res = await page.evaluate(async () => {
|
||||
const c2 = c._roomCenter(r2);
|
||||
const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
|
||||
// общая стена вертикальная — дверь ставим на неё
|
||||
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const H = 1000; // square canvas
|
||||
const doorPt = (() => {
|
||||
let best = null, bd = 1e9;
|
||||
for (const [x, y] of [[550, 150], [550, 200], [550, 250]]) {
|
||||
@@ -57,9 +57,8 @@ const res = await page.evaluate(async () => {
|
||||
...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
// источник детерминированно ставим в центр r1 (двигаем реальную включённую лампу)
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
const c1 = c._roomCenter(r1);
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / 1000 } };
|
||||
// радиус 6 м, чтобы дверь заведомо была в зоне досягаемости
|
||||
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
@@ -73,7 +72,7 @@ const res = await page.evaluate(async () => {
|
||||
const minX = Math.min(...poly1.map((p) => p[0]));
|
||||
const yMid = (Math.min(...poly1.map((p) => p[1])) + Math.max(...poly1.map((p) => p[1]))) / 2;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / (1000 / aspect), angle: 90, length: 0.09 }] })) };
|
||||
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / 1000, angle: 90, length: 0.09 }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const clipEls2 = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
|
||||
out.entranceNoSector = clipEls2.every((cp) => cp.querySelectorAll('path').length === 1);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Native HA controls in dialogs (ha-switch / ha-slider) + the fallback.
|
||||
// The demo env has no HA frontend, so every OTHER smoke already runs the
|
||||
// fallback branch (plain input[type=checkbox|range]). Here we check both:
|
||||
// 1) fallback: without ha-* the dialogs render the classic inputs;
|
||||
// 2) ha branch: after registering lightweight ha-switch/ha-slider stubs
|
||||
// (checked/value + `change`/`input`, the real contract of the HA ones)
|
||||
// a re-opened dialog renders them, values flow BOTH ways.
|
||||
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) fallback branch: no ha-* registered -> classic inputs ----------
|
||||
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
|
||||
out.fallbackCheckbox = !!sr().querySelector('.dialog .srcrow input[type=checkbox]');
|
||||
out.fallbackRange = !!sr().querySelector('.dialog input[type=range]');
|
||||
out.fallbackNoHaSwitch = !sr().querySelector('.dialog ha-switch');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
|
||||
// --- 2) register stubs mimicking the HA contract -----------------------
|
||||
customElements.define('ha-switch', class extends HTMLElement {
|
||||
#on = false;
|
||||
get checked() { return this.#on; }
|
||||
set checked(v) { this.#on = !!v; }
|
||||
get disabled() { return this.hasAttribute('disabled'); }
|
||||
set disabled(v) { v ? this.setAttribute('disabled', '') : this.removeAttribute('disabled'); }
|
||||
});
|
||||
customElements.define('ha-slider', class extends HTMLElement {
|
||||
#v = 0;
|
||||
get value() { return this.#v; }
|
||||
set value(v) { this.#v = Number(v); }
|
||||
set min(_) {} set max(_) {} set step(_) {}
|
||||
});
|
||||
|
||||
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
|
||||
const switches = [...sr().querySelectorAll('.dialog ha-switch')];
|
||||
out.haSwitchRendered = switches.length >= 2; // is_light + hide-from-plan at minimum
|
||||
out.haNoPlainCheckbox = !sr().querySelector('.dialog .srcrow input[type=checkbox]');
|
||||
const sliders = [...sr().querySelectorAll('.dialog ha-slider')];
|
||||
out.haSliderRendered = sliders.length >= 2; // size + angle
|
||||
|
||||
// --- 3) card -> control: state is pushed into the element --------------
|
||||
c._markerDialog = { ...c._markerDialog, isLight: true, hideFromPlan: false };
|
||||
await c.updateComplete;
|
||||
out.downstreamChecked = [...sr().querySelectorAll('.dialog ha-switch')].some((el) => el.checked === true);
|
||||
|
||||
// --- 4) control -> card: change with .checked lands in the dialog ------
|
||||
const before = c._markerDialog.hideFromPlan;
|
||||
const sw = [...sr().querySelectorAll('.dialog ha-switch')].find((el) => el.checked === before);
|
||||
sw.checked = !before;
|
||||
sw.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await c.updateComplete;
|
||||
out.upstreamChecked = c._markerDialog.hideFromPlan === !before || c._markerDialog.isLight === !before
|
||||
|| c._markerDialog.useClimateTemp === !before || c._markerDialog.tapConfirm === !before;
|
||||
|
||||
// --- 5) slider both ways ----------------------------------------------
|
||||
const sizeBefore = c._markerDialog.size;
|
||||
const sl = [...sr().querySelectorAll('.dialog ha-slider')].find((el) => Number(el.value) === Number(sizeBefore));
|
||||
out.downstreamValue = !!sl;
|
||||
sl.value = 2;
|
||||
sl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await c.updateComplete;
|
||||
out.upstreamValue = c._markerDialog.size === 2;
|
||||
// `change` (fired on release, HA-version dependent) reaches the same handler
|
||||
sl.value = 2.5;
|
||||
sl.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await c.updateComplete;
|
||||
out.upstreamValueChangeEvt = c._markerDialog.size === 2.5;
|
||||
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
|
||||
// --- 6) general settings: opacity sliders take the ha branch too -------
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
out.gsHaSliders = sr().querySelectorAll('.dialog ha-slider').length >= 9;
|
||||
const gsl = sr().querySelector('.dialog ha-slider');
|
||||
gsl.value = 55;
|
||||
gsl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await c.updateComplete;
|
||||
out.gsUpstream = Math.round((Object.values(c._settingsDialog.colors)[0].a) * 100) === 55;
|
||||
c._settingsDialog = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(res, {});
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,243 @@
|
||||
// docs/FILTERING.md: скрытие — явная галка. Конфиг материализуется сеятелем
|
||||
// (filter_seeded), галка в диалоге у всех устройств, «Показать скрытые» —
|
||||
// локальный режим редактора, скрытые рисуются призраками и только там.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
// --- сеятель материализует конфиг при загрузке ---------------------------
|
||||
out.configSeeded = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const t0 = Date.now();
|
||||
while (!c._serverCfg?.settings?.filter_seeded && Date.now() - t0 < 3000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
return c._serverCfg?.settings?.filter_seeded === true;
|
||||
});
|
||||
|
||||
// --- галка прячет, счётчик не считает, призрак только в редакторе --------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const visibleIds = () => [...sr().querySelectorAll('.dev')].length;
|
||||
const before = visibleIds();
|
||||
|
||||
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
|
||||
c._serverCfg.markers = c._serverCfg.markers || [];
|
||||
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
o.hiddenNotRendered = visibleIds() === before - 1;
|
||||
const built = c._devices.find((x) => x.id === d.id);
|
||||
o.stillBuilt = !!built && built.hidden === true;
|
||||
o.countExcludesHidden = (sr().querySelector('.count')?.textContent || '').includes(String(before - 1));
|
||||
|
||||
// в просмотре тумблера нет эффекта — призраки только в редакторе устройств
|
||||
c._showHidden = true; c.requestUpdate(); await c.updateComplete;
|
||||
o.noGhostsInView = !sr().querySelector('.dev.ghost');
|
||||
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
o.ghostInEditor = !!sr().querySelector('.dev.ghost');
|
||||
// призрак — конфигурация, не статус: ни жёлтого, ни unavail, ни тревоги
|
||||
const g = sr().querySelector('.dev.ghost');
|
||||
o.ghostHasNoState = !!g && !g.classList.contains('on') && !g.classList.contains('open')
|
||||
&& !g.classList.contains('unavail') && !g.classList.contains('alarm');
|
||||
// и он синий, а не тёмный — отличим от недоступного устройства
|
||||
o.ghostIsBlue = !!g && getComputedStyle(g).borderStyle.includes('dashed')
|
||||
&& getComputedStyle(g).borderColor !== 'rgb(255, 255, 255)';
|
||||
// тумблер локальный: конфиг не трогается
|
||||
o.toggleIsLocal = c._serverCfg.settings.show_all === undefined;
|
||||
|
||||
// --- галка в диалоге у авто-устройства, кнопки «Удалить» нет -----------
|
||||
const ghost = c._devices.find((x) => x.id === d.id);
|
||||
c._openMarkerDialog(ghost); await c.updateComplete;
|
||||
o.checkboxOn = c._markerDialog?.hideFromPlan === true;
|
||||
o.noDeleteForAuto = !sr().querySelector('.dialog .btn.danger');
|
||||
// снимаем галку и сохраняем — маркер остаётся с hidden:false (анти-повтор)
|
||||
c._markerDialog = { ...c._markerDialog, hideFromPlan: false };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const m = (c._serverCfg.markers || []).find((x) => x.binding === 'device:' + d.bindingRef);
|
||||
o.untickKeepsMarker = !!m && m.hidden === false;
|
||||
const back = c._devices.find((x) => x.id === d.id) || c._devices.find((x) => x.bindingRef === d.bindingRef);
|
||||
o.deviceVisibleAgain = !!back && !back.hidden;
|
||||
|
||||
// --- у виртуального кнопка «Удалить» есть -------------------------------
|
||||
c._openMarkerDialog(); await c.updateComplete;
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const virt = c._devices.find((x) => x.virtual);
|
||||
c._openMarkerDialog(virt); await c.updateComplete;
|
||||
o.deleteForVirtual = !!sr().querySelector('.dialog .btn.danger');
|
||||
c._markerDialog = null; c._setMode('view');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1510-02: призрак не показывает live-значения ----------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// датчик с числом + display:value, скрыт
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'sensor.power_meter': { state: '42', attributes: { unit_of_measurement: 'kW' } } } };
|
||||
c._serverCfg.markers = c._serverCfg.markers || [];
|
||||
c._serverCfg.markers.push({ id: 'pm', binding: 'entity:sensor.power_meter',
|
||||
display: 'value', hidden: true, space: 'f1' });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('devices'); c._showHidden = true;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
|
||||
const pm = ghosts.find((g) => g.textContent.includes('42')) || null;
|
||||
o.ghostHidesValue = pm === null; // «42 kW» не отрисован
|
||||
o.ghostNoLiveBadges = ghosts.every((g) =>
|
||||
!g.querySelector('.valtext') && !g.querySelector('.tval') && !g.querySelector('.hval') && !g.querySelector('.lqi'));
|
||||
// у КАЖДОГО призрака есть базовая иконка — «noicon» не оправдание
|
||||
// (HP-1511-02: ripple-призрак был безликим пульсом)
|
||||
o.ghostKeepsIcon = ghosts.every((g) => !!g.querySelector('ha-icon') && !g.classList.contains('noicon'));
|
||||
c._setMode('view'); c._showHidden = false;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1511-02: ripple-призрак с базовой иконкой, без пульса -------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const lamp = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device' && !x.hidden);
|
||||
c._serverCfg.markers = c._serverCfg.markers || [];
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
|
||||
c._serverCfg.markers.push({ id: lamp.id, binding: 'device:' + lamp.bindingRef,
|
||||
display: 'ripple', hidden: true });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('devices'); c._showHidden = true;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const g = [...sr().querySelectorAll('.dev.ghost')].find((x) => x.querySelector('.ripple') || !x.classList.contains('noicon'));
|
||||
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
|
||||
o.rippleGhostHasIcon = ghosts.length > 0 && ghosts.every((x) => !!x.querySelector('ha-icon'));
|
||||
o.rippleGhostNoNoicon = ghosts.every((x) => !x.classList.contains('noicon'));
|
||||
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.ripple'));
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c._setMode('view'); c._showHidden = false;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1511-01: авто-сетка одинакова на обеих карточках ------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
// один видимый, остальные зоны living_room скрыты; layout пуст
|
||||
const vis = c._devices.filter((x) => x.area === 'living_room' && !x.virtual);
|
||||
const keep = vis[0];
|
||||
for (const d of vis.slice(1)) {
|
||||
cfg.markers = (cfg.markers || []).filter((m) => m.id !== d.id);
|
||||
cfg.markers.push({ id: d.id, binding: d.bindingKind + ':' + d.bindingRef, hidden: true });
|
||||
}
|
||||
cfg.markers = cfg.markers.filter((m) => m.id !== keep.id || !m.hidden);
|
||||
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
|
||||
c._layout = {}; // пустой layout: работают только авто-позиции
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const full = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.dev')]
|
||||
.find((el) => el.style.left && !el.classList.contains('ghost'));
|
||||
// проценты полной карточки считаются от content-fit view — переводим в vb
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const fullVb = full && [
|
||||
v.x + (parseFloat(full.style.left) / 100) * v.w,
|
||||
v.y + (parseFloat(full.style.top) / 100) * v.h,
|
||||
];
|
||||
|
||||
const hass = { ...c.hass, callWS: async (m) => {
|
||||
// rev уникален: у статичной карточки модульный кэш конфига по rev,
|
||||
// и одинаковый rev в соседних тестах подсовывает чужой конфиг
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 31 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 31 };
|
||||
return { ok: true };
|
||||
},
|
||||
// модульный кэш конфига инвалидируется только через это событие — стаб
|
||||
// сохраняет колбэк, чтобы следующий тест мог сбросить кэш
|
||||
connection: { subscribeEvents: async (cb) => { window.__hpInvalidate = cb; return () => {}; } } };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('[style*="left"]') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const st = [...card.renderRoot.querySelectorAll('[style*="left"]')]
|
||||
.find((el) => /%$/.test(el.style.left || '') && el.style.top);
|
||||
// docs/CANVAS.md §4: статичная карточка теперь кадрирует СОДЕРЖИМОЕ, как и
|
||||
// полная, а не квадрат 0..1000 — координаты берём из её же viewBox
|
||||
const svb = (card.renderRoot.querySelector('svg')?.getAttribute('viewBox') || '0 0 1000 1000')
|
||||
.trim().split(/\s+/).map(Number);
|
||||
const stVb = st && [
|
||||
svb[0] + (parseFloat(st.style.left) / 100) * svb[2],
|
||||
svb[1] + (parseFloat(st.style.top) / 100) * svb[3],
|
||||
];
|
||||
o.autoGridParity = !!fullVb && !!stVb
|
||||
&& Math.abs(fullVb[0] - stVb[0]) < 6 && Math.abs(fullVb[1] - stVb[1]) < 6;
|
||||
if (!o.autoGridParity) console.log('full', fullVb, 'static', stVb);
|
||||
host.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1510-01: LQI комнаты одинаков на полной и статичной карточке ------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1.settings = { ...(f1.settings || {}), fill_mode: 'lqi', show_borders: true };
|
||||
// прячем ВСЕ устройства зоны living_room: комнату красит только скрытое
|
||||
for (const d of c._devices.filter((x) => x.area === 'living_room' && !x.virtual)) {
|
||||
if (!cfg.markers.some((m) => m.id === d.id)) {
|
||||
cfg.markers.push({ id: d.id, binding: d.bindingKind + ':' + d.bindingRef, hidden: true });
|
||||
} else {
|
||||
cfg.markers = cfg.markers.map((m) => (m.id === d.id ? { ...m, hidden: true } : m));
|
||||
}
|
||||
}
|
||||
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const fullRoom = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.room')]
|
||||
.find((r) => (r.getAttribute('style') || '').includes('--room-fill'));
|
||||
const fullFill = fullRoom ? (fullRoom.getAttribute('style').match(/--room-fill:([^;]+)/) || [])[1] : null;
|
||||
|
||||
window.__hpInvalidate?.(); // сбросить модульный кэш от предыдущей карточки
|
||||
const hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 57 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 57 };
|
||||
return { ok: true };
|
||||
},
|
||||
connection: { subscribeEvents: async () => () => {} } };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.room') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const stRoom = [...card.renderRoot.querySelectorAll('.room')]
|
||||
.find((r) => (r.getAttribute('style') || '').includes('--room-fill'));
|
||||
const stFill = stRoom ? (stRoom.getAttribute('style').match(/--room-fill:([^;]+)/) || [])[1] : null;
|
||||
o.fullPaintsHiddenLqi = !!fullFill;
|
||||
o.staticPaintsHiddenLqi = !!stFill;
|
||||
o.lqiParity = !!fullFill && fullFill === stFill;
|
||||
host.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,94 @@
|
||||
// Множитель «размер значка» обязан масштабировать ГЛИФ вместе с подложкой:
|
||||
// --mdc-icon-size считался от базового --icon-size, и «увеличенный» значок
|
||||
// был пустой рамкой вокруг дефолтной иконки (репорт пользователя, 2026-07-29).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
|
||||
const glyphW = () => {
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => !e.classList.contains('ghost'));
|
||||
const ic = el?.querySelector('ha-icon');
|
||||
return ic ? ic.getBoundingClientRect().width : 0;
|
||||
};
|
||||
const badgeW = () => {
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => !e.classList.contains('ghost'));
|
||||
return el ? el.getBoundingClientRect().width : 0;
|
||||
};
|
||||
// базовый размер
|
||||
const g1 = glyphW(), b1 = badgeW();
|
||||
// множитель ×3 на всех авто-устройствах через маркер первого
|
||||
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== d.id);
|
||||
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, size: 3 });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const scaled = [...sr().querySelectorAll('.dev')].find((e) => (e.getAttribute('style') || '').includes('--dev-scale:3'));
|
||||
const g3 = scaled?.querySelector('ha-icon')?.getBoundingClientRect().width || 0;
|
||||
const b3 = scaled?.getBoundingClientRect().width || 0;
|
||||
o.badgeScales = b3 > b1 * 2.5;
|
||||
o.glyphScalesWithBadge = g3 > g1 * 2.5; // раньше глиф оставался базовым
|
||||
o.glyphFillsBadge = g3 / b3 > 0.45 && g3 / b3 < 0.8; // пропорция сохранена
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== d.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
|
||||
// Тот же класс бага для СПУТНИКОВ значка (репорт владельца, 2026-08-01):
|
||||
// бейдж значения (display:value) и temp-плашка считались от базового
|
||||
// --icon-size и оставались маленькими при size=2. Всё, что визуально
|
||||
// принадлежит устройству, обязано расти вместе с --dev-scale.
|
||||
const out2 = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const devEl = () => {
|
||||
const idx = c._devices.filter((x) => x.space === c._space).findIndex((x) => x.id === 'd_temp');
|
||||
return sr().querySelectorAll('.dev')[idx];
|
||||
};
|
||||
const setMarker = async (extra) => {
|
||||
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== 'd_temp');
|
||||
c._serverCfg.markers.push({ id: 'd_temp', binding: 'device:d_temp', ...extra });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
};
|
||||
const fs = (el) => parseFloat(getComputedStyle(el).fontSize);
|
||||
// базовый --icon-size в px: ширина немасштабированного бейджа минус рамка 1px×2
|
||||
const plain = [...sr().querySelectorAll('.dev')].find(
|
||||
(e) => !e.classList.contains('ghost') && !e.classList.contains('valonly') && !(e.getAttribute('style') || '').includes('--dev-scale'));
|
||||
const iconPx = plain.getBoundingClientRect().width - 2;
|
||||
|
||||
// --- бейдж значения (valonly) ---
|
||||
await setMarker({ display: 'value', size: 1 });
|
||||
const v1 = devEl().getBoundingClientRect();
|
||||
const vf1 = fs(devEl().querySelector('.valtext'));
|
||||
// при size=1 дефолт не изменился: шрифт ровно прежние 0.45 * icon-size
|
||||
o.valDefaultKept = Math.abs(vf1 - iconPx * 0.45) < 0.75;
|
||||
await setMarker({ display: 'value', size: 2 });
|
||||
const v2 = devEl().getBoundingClientRect();
|
||||
const vf2 = fs(devEl().querySelector('.valtext'));
|
||||
o.valFontScales = vf2 / vf1 > 1.8 && vf2 / vf1 < 2.2;
|
||||
o.valPlateScales = (v2.height - 2) / (v1.height - 2) > 1.8 && (v2.height - 2) / (v1.height - 2) < 2.2
|
||||
&& v2.width / v1.width > 1.6 && v2.width / v1.width < 2.4;
|
||||
|
||||
// --- temp-плашка (.tval) рядом со значком ---
|
||||
await setMarker({ size: 1 });
|
||||
const t1 = devEl().querySelector('.tval').getBoundingClientRect();
|
||||
const tf1 = fs(devEl().querySelector('.tval'));
|
||||
// дефолт: line-height 0.68 * icon-size + рамка 1px×2
|
||||
o.tvalDefaultKept = Math.abs(t1.height - (iconPx * 0.68 + 2)) < 1;
|
||||
await setMarker({ size: 2 });
|
||||
const t2 = devEl().querySelector('.tval').getBoundingClientRect();
|
||||
const tf2 = fs(devEl().querySelector('.tval'));
|
||||
o.tvalScales = (t2.height - 2) / (t1.height - 2) > 1.8 && (t2.height - 2) / (t1.height - 2) < 2.2
|
||||
&& tf2 / tf1 > 1.8 && tf2 / tf1 < 2.2;
|
||||
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== 'd_temp');
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out2));
|
||||
@@ -0,0 +1,359 @@
|
||||
// Infinite canvas (docs/CANVAS.md): a plan drawn far past the old unit square
|
||||
// renders whole, devices can be placed and saved out there, the opening view
|
||||
// follows the content, one far stray neither breaks the view nor hides itself,
|
||||
// zoom-out stops at 3x the content and the icons keep their proportion to it.
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const SHOTS = process.env.HP_SHOTS || '';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
const out = {};
|
||||
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
// A hand-drawn plan whose rooms live at 1.5..3.0 — entirely outside the old
|
||||
// canvas. Before the infinite canvas the -25%..125% envelope threw every one
|
||||
// of these vertices away and the card framed empty space.
|
||||
const FAR_CFG = {
|
||||
spaces: [{
|
||||
id: 'f1', title: 'Ground floor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [
|
||||
{ id: 'r1', name: 'Living room', area: 'living_room',
|
||||
poly: [[1.5, 1.5], [2.2, 1.5], [2.2, 2.2], [1.5, 2.2]] },
|
||||
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
|
||||
poly: [[2.2, 1.5], [3.0, 1.5], [3.0, 2.0], [2.2, 2.0]] },
|
||||
{ id: 'r3', name: 'Bedroom', area: 'bedroom',
|
||||
poly: [[2.2, 2.0], [3.0, 2.0], [3.0, 2.6], [2.2, 2.6]] },
|
||||
{ id: 'r4', name: 'Hallway', area: 'hallway',
|
||||
poly: [[1.5, 2.2], [2.2, 2.2], [2.2, 2.6], [1.5, 2.6]] },
|
||||
],
|
||||
openings: [{ id: 'o1', type: 'window', x: 1.8, y: 1.5, angle: 0, length: 0.15 }],
|
||||
decor: [{ id: 'd1', kind: 'line', x1: 1.4, y1: 2.7, x2: 3.1, y2: 2.7, color: '#8899aa' }],
|
||||
}, {
|
||||
id: 'garden', title: 'Garden', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'g1', name: 'Garden', area: 'garden',
|
||||
poly: [[0.03, 0.178], [0.97, 0.178], [0.97, 0.822], [0.03, 0.822]] }],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const FAR_LAYOUT = {
|
||||
d_light1: { s: 'f1', x: 1.7, y: 1.7 }, d_lamp: { s: 'f1', x: 2.0, y: 1.9 },
|
||||
d_tv: { s: 'f1', x: 1.6, y: 2.1 }, d_temp: { s: 'f1', x: 1.9, y: 1.6 },
|
||||
d_kettle: { s: 'f1', x: 2.5, y: 1.7 }, d_leak: { s: 'f1', x: 2.9, y: 1.8 },
|
||||
d_bedlight: { s: 'f1', x: 2.6, y: 2.3 }, d_window: { s: 'f1', x: 2.9, y: 2.5 },
|
||||
d_lock: { s: 'f1', x: 1.6, y: 2.5 }, d_motion: { s: 'f1', x: 2.0, y: 2.4 },
|
||||
};
|
||||
|
||||
const loadFar = (cfg, layout) => page.evaluate(async ([c2, l2]) => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = JSON.parse(JSON.stringify(c2));
|
||||
c._layout = JSON.parse(JSON.stringify(l2));
|
||||
c._modelCache = null;
|
||||
c._frame = null;
|
||||
c._showFar = false;
|
||||
c._space = 'f1';
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c._view = null;
|
||||
c._zoom = 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._resetZoom();
|
||||
await c.updateComplete;
|
||||
}, [cfg, layout]);
|
||||
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
|
||||
// ---- (b) the plan past the old square renders WHOLE ----------------------
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const b = c._baseVb();
|
||||
// the content is 1500..3000 x 1500..2700 render units, plus decor/openings
|
||||
o.frameHoldsFarPlan = b[0] < 1450 && b[0] + b[2] > 3050 && b[1] < 1450 && b[1] + b[3] > 2750;
|
||||
o.frameIsNotTheOldSquare = b[0] > 1000; // nothing is framed at the origin any more
|
||||
const v = c._viewOr(b);
|
||||
o.viewShowsFarPlan = v.x <= 1500 && v.x + v.w >= 3000;
|
||||
// every room polygon is inside the painted viewBox
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const polys = [...sr.querySelectorAll('polygon.room')];
|
||||
o.allRoomsDrawn = polys.length === 4;
|
||||
const st = sr.querySelector('.stage').getBoundingClientRect();
|
||||
o.roomsOnScreen = polys.every((p) => {
|
||||
const r = p.getBoundingClientRect();
|
||||
return r.width > 4 && r.height > 4 && r.left >= st.left - 2 && r.right <= st.right + 2
|
||||
&& r.top >= st.top - 2 && r.bottom <= st.bottom + 2;
|
||||
});
|
||||
// and the markers landed on the plan, not in a corner of a phantom canvas
|
||||
const devs = [...sr.querySelectorAll('.devlayer .dev')];
|
||||
o.markersOnPlan = devs.length >= 10 && devs.every((d) => {
|
||||
const r = d.getBoundingClientRect();
|
||||
return r.left >= st.left - 2 && r.right <= st.right + 2 && r.top >= st.top - 2 && r.bottom <= st.bottom + 2;
|
||||
});
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- a device can be PLACED past the old square, and it saves -------------
|
||||
const ws = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sent = [];
|
||||
const base = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return base(m); } };
|
||||
// drop a virtual marker at 3.4 / 2.9 — a long way outside the old canvas
|
||||
const far = { s: 'f1', x: 3.4, y: 2.9 };
|
||||
c._layout = { ...c._layout, d_gate: far };
|
||||
c._dirtyPos.add('d_gate');
|
||||
await c._persistLayout?.();
|
||||
// and a room extended even further out, saved through the config writer
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].rooms.push({ id: 'r5', name: 'Workshop',
|
||||
poly: [[3.1, 2.0], [3.8, 2.0], [3.8, 2.6], [3.1, 2.6]] });
|
||||
c._serverCfg = cfg; c._modelCache = null; c._frame = null;
|
||||
await c._saveConfig?.();
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
return { sent: JSON.parse(JSON.stringify(sent)), layout: JSON.parse(JSON.stringify(c._layout)) };
|
||||
});
|
||||
out.farDeviceSaved = ws.layout.d_gate.x === 3.4 && ws.layout.d_gate.y === 2.9;
|
||||
const cfgMsg = ws.sent.filter((m) => m.type === 'houseplan/config/set').pop();
|
||||
const layMsg = ws.sent.filter((m) => m.type === 'houseplan/layout/update').pop();
|
||||
out.wsCarriedFarGeometry = !!cfgMsg
|
||||
&& JSON.stringify(cfgMsg.config).includes('3.8');
|
||||
out.wsCarriedFarPosition = !!layMsg && JSON.stringify(layMsg).includes('3.4');
|
||||
|
||||
// the payload the card sends must pass the REAL backend schema (docs/CANVAS.md
|
||||
// §3: ±5000). Skipped, not failed, where voluptuous is not installed.
|
||||
out.backendAcceptsFarPayload = (() => {
|
||||
if (!cfgMsg) return false;
|
||||
const f = '/tmp/hp_infinite_payload.json';
|
||||
writeFileSync(f, JSON.stringify({ config: cfgMsg.config, layout: (layMsg || {}).layout || ws.layout }));
|
||||
try {
|
||||
execFileSync('python3', ['-c', 'import voluptuous'], { stdio: 'ignore' });
|
||||
} catch { console.log('(voluptuous absent — backend schema check skipped)'); return true; }
|
||||
const code = [
|
||||
'import json,sys',
|
||||
'sys.path.insert(0, "custom_components/houseplan")',
|
||||
'import validation as v',
|
||||
`d=json.load(open(${JSON.stringify(f)}))`,
|
||||
'v.CONFIG_SCHEMA(d["config"]); v.LAYOUT_SCHEMA(d["layout"])',
|
||||
'print("VALID")',
|
||||
].join('\n');
|
||||
try {
|
||||
return execFileSync('python3', ['-c', code], { encoding: 'utf8' }).includes('VALID');
|
||||
} catch (e) { console.log('backend refused the payload:', String(e.stdout || e.message).slice(-400)); return false; }
|
||||
})();
|
||||
|
||||
// ---- an OUTLIER: the view ignores it, the hint offers it ------------------
|
||||
await loadFar({
|
||||
...FAR_CFG,
|
||||
spaces: FAR_CFG.spaces.map((s) => (s.id === 'f1' ? { ...s } : s)),
|
||||
}, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } });
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
o.outlierReported = c._outliers === 1;
|
||||
const b = c._baseVb();
|
||||
o.outlierDoesNotCommandView = b[2] < 4000 && b[0] + b[2] < 5000; // still the house, not 90 canvases
|
||||
o.outlierViewStillHoldsPlan = b[0] < 1500 && b[0] + b[2] > 3000;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const hint = sr.querySelector('.farhint');
|
||||
o.hintShown = !!hint && !!hint.querySelector('button');
|
||||
o.hintIsNotAModal = !sr.querySelector('.dialogwrap');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// «Показать» / «Вписать всё» takes the stray in
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
sr.querySelector('.farhint button').click();
|
||||
await c.updateComplete;
|
||||
const o = {};
|
||||
const v = c._viewOr(c._baseVb());
|
||||
o.fitAllReachesTheStray = v.x + v.w > 90000 && v.y + v.h > 90000;
|
||||
o.fitAllStillHoldsPlan = v.x <= 1500 && v.y <= 1500;
|
||||
await c.updateComplete;
|
||||
o.hintGoneAfterShow = !sr.querySelector('.farhint');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- zoom-out stops at 3x, zoom-in unchanged -----------------------------
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
c._resetZoom();
|
||||
const fit = { ...c._viewOr(c._baseVb()) };
|
||||
c._applyView(0.001);
|
||||
o.zoomOutStopsAtThree = Math.abs(c._zoom - 1 / 3) < 1e-9
|
||||
&& Math.abs(c._view.w - fit.w * 3) < fit.w * 0.02;
|
||||
c._applyView(50);
|
||||
o.zoomInUnchanged = c._zoom === 8;
|
||||
c._resetZoom();
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- pan has slack, and "home is that way" brings you back ---------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const fit = { ...c._view };
|
||||
// pan most of a screen to the right: nothing pins the content over the scene
|
||||
c._view = c._clampView({ ...fit, x: fit.x + fit.w * 0.9 }, fit);
|
||||
o.panPastContentAllowed = c._view.x > fit.x + fit.w * 0.5;
|
||||
// walk right off the plan: the frame is entirely outside the view now
|
||||
c._view = { ...fit, x: fit.x + fit.w * 1.6 };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const arrow = sr.querySelector('.homearrow');
|
||||
o.homeArrowAppears = !!arrow;
|
||||
if (arrow) {
|
||||
arrow.click();
|
||||
await c.updateComplete;
|
||||
o.homeArrowFitsBack = Math.abs(c._view.x - fit.x) < 2 && Math.abs(c._view.w - fit.w) < 2;
|
||||
await c.updateComplete;
|
||||
o.homeArrowGone = !sr.querySelector('.homearrow');
|
||||
}
|
||||
// and it is NOT there while the plan is on screen
|
||||
c._resetZoom(); c.requestUpdate(); await c.updateComplete;
|
||||
o.noHomeArrowWhenVisible = !sr.querySelector('.homearrow');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- icons scale WITH THE PLAN across zooms (docs/CANVAS.md §6) ----------
|
||||
// The infinite canvas briefly made --icon-size a percentage of the viewport,
|
||||
// so a marker kept its pixel size at every zoom. The owner looked at it and
|
||||
// asked for the original contract back (2026-08-03): an icon is a percentage
|
||||
// of the PLAN, it grows when you zoom in and shrinks when you zoom out, like
|
||||
// everything else drawn on the plan. What the infinite canvas contributes is
|
||||
// only the base unit the percentage is taken OF: `iconUnit` instead of the
|
||||
// stored `vb.w`, checked right below on this very plan — which is drawn
|
||||
// entirely outside the old square.
|
||||
const farIcon = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
// the badge is --dev-size PLUS a 1px border on each side, and the border
|
||||
// is not part of the scaled geometry (same correction as smoke_icon_scale)
|
||||
const size = () => {
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
return Math.round((el.getBoundingClientRect().width - 2) * 100) / 100;
|
||||
};
|
||||
const o = {};
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const at1 = size();
|
||||
c._applyView(4); await c.updateComplete;
|
||||
const at4 = size();
|
||||
c._applyView(1 / 3); await c.updateComplete;
|
||||
const atOut = size();
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
o.iconGrowsWithZoomIn = at1 > 4 && Math.abs(at4 / at1 - 4) < 0.05;
|
||||
o.iconShrinksWithZoomOut = Math.abs(atOut / at1 - 1 / 3) < 0.02;
|
||||
if (!o.iconGrowsWithZoomIn || !o.iconShrinksWithZoomOut) console.log('icon px', at1, at4, atOut);
|
||||
return { o, at1 };
|
||||
});
|
||||
Object.assign(out, farIcon.o);
|
||||
|
||||
// ---- an OLD plan (small, with a view_box) opens exactly as before ---------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const cfg = {
|
||||
spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Living', area: 'living_room',
|
||||
poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
|
||||
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
|
||||
poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
c._serverCfg = cfg; c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
|
||||
c._modelCache = null; c._frame = null; c._showFar = false; c._view = null;
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions(); // the auto grid follows the new rooms
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const b = c._baseVb();
|
||||
// identical to what contentBounds produced before: the content bbox
|
||||
// (40..960 x 140..580) plus 5% of the longer side (920 -> 46)
|
||||
o.legacyFrameUnchanged = Math.abs(b[0] - -6) < 1 && Math.abs(b[1] - 94) < 1
|
||||
&& Math.abs(b[2] - 1012) < 1 && Math.abs(b[3] - 532) < 1;
|
||||
o.legacyNoHint = c._outliers === 0;
|
||||
// an EMPTY space falls back to the stored view_box hint (§4)
|
||||
c._serverCfg = { spaces: [{ id: 'f1', title: 'E', view_box: [0.1, 0.2, 0.5, 0.4], rooms: [] }],
|
||||
markers: [], settings: {} };
|
||||
// a space with NOTHING in it at all — not even a marker parked in it
|
||||
c._layout = {}; c._modelCache = null; c._frame = null;
|
||||
c._devices = [];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const e = c._baseVb();
|
||||
o.emptySpaceUsesViewBoxHint = JSON.stringify(e.map(Math.round)) === '[100,200,500,400]';
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- and the icon does not DEGENERATE on the runaway plan ----------------
|
||||
// The catch of "a percentage of the plan" on an unbounded canvas: keep the
|
||||
// old fixed numerator (vb.w = NORM_W) and a plan drawn 2.3 canvases wide
|
||||
// divides it by a 2.5-canvas-wide frame — the marker shrinks by that factor
|
||||
// and keeps shrinking the further out the plan is drawn. `iconUnit` grows
|
||||
// with the plan, so the marker on the far plan measured above is the same
|
||||
// number of pixels as on this ordinary 0..1 one.
|
||||
out.farIconNotDegenerate = await page.evaluate(async (farPx) => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
// the small plan from the block above, with its devices back
|
||||
c._serverCfg = { spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Living', area: 'living_room',
|
||||
poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
|
||||
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
|
||||
poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }],
|
||||
markers: [], settings: {} };
|
||||
c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
|
||||
c._modelCache = null; c._frame = null; c._showFar = false; c._view = null;
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
const px = el.getBoundingClientRect().width - 2;
|
||||
if (!(px > 4 && Math.abs(px / farPx - 1) < 0.3)) console.log('icon px near/far', px, farPx);
|
||||
return px > 4 && Math.abs(px / farPx - 1) < 0.3;
|
||||
}, farIcon.at1);
|
||||
|
||||
// ---- screenshots ---------------------------------------------------------
|
||||
if (SHOTS) {
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_far_plan.png` });
|
||||
// fit-all with a stray far away
|
||||
await loadFar(FAR_CFG, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } });
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_far_hint.png` });
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
(c.shadowRoot || c.renderRoot).querySelector('.farhint button').click();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_fit_all.png` });
|
||||
// adaptive grid, zoomed far out in the plan editor
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
c._resetZoom(); c._applyView(1 / 3); await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_grid_far.png` });
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._resetZoom(); c._applyView(4); await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_grid_near.png` });
|
||||
await page.evaluate(async () => { window.__card._setMode('view'); });
|
||||
}
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,138 @@
|
||||
// A kiosk gesture is classified ONCE, and the release obeys that decision —
|
||||
// audit DEV-1DA1-02 (P2).
|
||||
//
|
||||
// `_stagePointerMove` locks the gesture on the first movement past 8 px
|
||||
// (`_panLock`: 'swipe' if it is horizontal enough inside the swipe zone, 'pan'
|
||||
// otherwise) and the plan then follows the finger. But `_stagePointerUp` used
|
||||
// to ignore that lock and ask `swipeTarget()` again, from the raw start→end
|
||||
// vector alone. A CURVED gesture — a small vertical lead-in that locks 'pan',
|
||||
// then a long horizontal sweep — therefore panned under the finger and still
|
||||
// switched the floor on release. On a wall tablet that is the worst kind of
|
||||
// surprise: you watch the plan drag along and land on another storey.
|
||||
//
|
||||
// The lock is now final: with `_panLock === 'pan'` the floor never changes, no
|
||||
// matter what the overall vector looks like. The two straight gestures keep
|
||||
// their old behaviour, and so does the motionless double tap (no movement,
|
||||
// no lock — the swipe path is never even reached by it).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c0 = window.__card;
|
||||
const k = document.createElement('houseplan-card');
|
||||
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
k.hass = c0.hass;
|
||||
document.body.appendChild(k);
|
||||
k.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
k.hass = { ...c0.hass };
|
||||
await k.updateComplete;
|
||||
const sr = k.shadowRoot || k.renderRoot;
|
||||
const stage = sr.querySelector('.stage');
|
||||
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
|
||||
}));
|
||||
o.kioskHasSeveralSpaces = k._model.length > 1;
|
||||
|
||||
const home = async () => {
|
||||
const vb = k._baseVb();
|
||||
k._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||
await k.updateComplete;
|
||||
};
|
||||
/**
|
||||
* Play one trajectory and report what the gesture decided and what it did.
|
||||
* `pts` are the moves; the finger lifts at the last one.
|
||||
*/
|
||||
const play = async (id, from, pts) => {
|
||||
await home();
|
||||
const s0 = k._space;
|
||||
const v0 = { ...k._viewOr(k._baseVb()) };
|
||||
fire('pointerdown', id, from[0], from[1]);
|
||||
let lockAfterLeadIn = null;
|
||||
let moved = false;
|
||||
pts.forEach((p, i) => {
|
||||
fire('pointermove', id, p[0], p[1]);
|
||||
if (i === 0) lockAfterLeadIn = k._panLock;
|
||||
if (Math.abs(k._viewOr(k._baseVb()).x - v0.x) > 1
|
||||
|| Math.abs(k._viewOr(k._baseVb()).y - v0.y) > 1) moved = true;
|
||||
});
|
||||
const lockBeforeRelease = k._panLock;
|
||||
const last = pts[pts.length - 1];
|
||||
fire('pointerup', id, last[0], last[1]);
|
||||
await k.updateComplete;
|
||||
return { s0, s1: k._space, lockAfterLeadIn, lockBeforeRelease, moved };
|
||||
};
|
||||
|
||||
// ---- 1. the auditor's curved pan: vertical lead-in, horizontal ending --
|
||||
// (500,300) → (502,312) locks 'pan' → curve left to (350,304) → release.
|
||||
// dx = -150, dy = +4: swipeTarget() would happily call that a swipe.
|
||||
const curvedPan = await play(51, [500, 300], [[502, 312], [460, 310], [400, 306], [350, 304]]);
|
||||
o.curvedPanLocksPan = curvedPan.lockAfterLeadIn === 'pan';
|
||||
o.curvedPanKeepsTheLock = curvedPan.lockBeforeRelease === 'pan';
|
||||
o.curvedPanActuallyPans = curvedPan.moved === true;
|
||||
o.curvedPanKeepsTheFloor = curvedPan.s1 === curvedPan.s0;
|
||||
// the same trajectory the other way round — a swipe to the right would have
|
||||
// been the previous floor, so the bug is symmetric and so is the fix
|
||||
const curvedPanRight = await play(52, [400, 300], [[402, 312], [460, 308], [520, 305], [560, 304]]);
|
||||
o.curvedPanRightLocksPan = curvedPanRight.lockAfterLeadIn === 'pan';
|
||||
o.curvedPanRightKeepsTheFloor = curvedPanRight.s1 === curvedPanRight.s0;
|
||||
// a long diagonal that ends up dominated by x, but started as a pan
|
||||
const diagonalPan = await play(53, [500, 300], [[496, 316], [420, 340], [330, 350], [260, 352]]);
|
||||
o.diagonalPanLocksPan = diagonalPan.lockAfterLeadIn === 'pan';
|
||||
o.diagonalPanKeepsTheFloor = diagonalPan.s1 === diagonalPan.s0;
|
||||
|
||||
// ---- 2. a gesture locked as a SWIPE keeps its own semantics ------------
|
||||
// horizontal lead-in locks 'swipe'; the plan must not slide under it, even
|
||||
// when the trajectory then bends vertically and the final vector no longer
|
||||
// qualifies — the floor simply stays, and nothing pans
|
||||
const curvedSwipe = await play(54, [600, 300], [[540, 302], [536, 380], [545, 500]]);
|
||||
o.curvedSwipeLocksSwipe = curvedSwipe.lockAfterLeadIn === 'swipe';
|
||||
o.curvedSwipeNeverPans = curvedSwipe.moved === false;
|
||||
o.curvedSwipeThatDiesChangesNothing = curvedSwipe.s1 === curvedSwipe.s0;
|
||||
// a swipe that bends but still ends as a swipe does switch the floor
|
||||
const bentSwipe = await play(55, [600, 300], [[540, 302], [470, 330], [420, 340]]);
|
||||
o.bentSwipeLocksSwipe = bentSwipe.lockAfterLeadIn === 'swipe';
|
||||
o.bentSwipeNeverPans = bentSwipe.moved === false;
|
||||
o.bentSwipeStillSwitches = bentSwipe.s1 !== bentSwipe.s0;
|
||||
|
||||
// ---- 3. the straight gestures are exactly as they were -----------------
|
||||
const straightSwipe = await play(56, [600, 300], [[540, 302], [480, 305], [450, 305]]);
|
||||
o.straightSwipeSwitches = straightSwipe.s1 !== straightSwipe.s0;
|
||||
o.straightSwipeDoesNotPan = straightSwipe.moved === false;
|
||||
const straightPan = await play(57, [450, 200], [[452, 260], [454, 330]]);
|
||||
o.straightPanPans = straightPan.moved === true;
|
||||
o.straightPanKeepsTheFloor = straightPan.s1 === straightPan.s0;
|
||||
|
||||
// ---- 4. a motionless double tap still resets the zoom ------------------
|
||||
// no movement means no lock at all, so nothing above can reach this path
|
||||
await home();
|
||||
k._applyView(2);
|
||||
await k.updateComplete;
|
||||
k._lastTap = Date.now() - 100;
|
||||
fire('pointerdown', 58, 500, 300);
|
||||
fire('pointerup', 58, 501, 300);
|
||||
await k.updateComplete;
|
||||
o.doubleTapStillResetsZoom = k._zoom === 1;
|
||||
o.doubleTapLeavesNoLock = k._panLock === null;
|
||||
|
||||
// ---- 5. zoomed in there is no swipe zone, and the lock says so ---------
|
||||
k._applyView(2);
|
||||
await k.updateComplete;
|
||||
const zoomed = await (async () => {
|
||||
const s0 = k._space;
|
||||
fire('pointerdown', 59, 600, 300);
|
||||
fire('pointermove', 59, 540, 302);
|
||||
const lock = k._panLock;
|
||||
fire('pointermove', 59, 480, 305);
|
||||
fire('pointerup', 59, 480, 305);
|
||||
await k.updateComplete;
|
||||
return { lock, same: k._space === s0 };
|
||||
})();
|
||||
o.zoomedHorizontalLocksPan = zoomed.lock === 'pan';
|
||||
o.zoomedHorizontalKeepsTheFloor = zoomed.same;
|
||||
|
||||
k.remove();
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,103 @@
|
||||
// HP-1460-03: позиции — отдельное состояние. В v1.46.0 событие layout_updated
|
||||
// научилась слушать статическая карточка, а полная — нет, поэтому две полные
|
||||
// карточки рядом расходились до перезагрузки. Проверяем и обратное: приход
|
||||
// чужой ревизии не должен затирать перетаскивание, которое ещё не улетело.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
|
||||
// общий «сервер»: layout с ревизией и подписчики на событие
|
||||
let rev = 5;
|
||||
let layout = { dev_a: { x: 10, y: 10 }, dev_b: { x: 20, y: 20 } };
|
||||
const subs = [];
|
||||
let gets = 0;
|
||||
const hass = { ...c.hass,
|
||||
callWS: async (m) => {
|
||||
if (m.type === 'houseplan/layout/get') { gets++; return { layout: JSON.parse(JSON.stringify(layout)), rev }; }
|
||||
if (m.type === 'houseplan/layout/update') {
|
||||
layout = { ...layout, [m.device_id]: m.pos }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
return { ok: true, rev };
|
||||
}
|
||||
return base(m);
|
||||
},
|
||||
connection: { subscribeEvents: async (cb, ev) => {
|
||||
if (ev === 'houseplan_layout_updated') { subs.push(cb); return () => {}; }
|
||||
return () => {};
|
||||
} },
|
||||
};
|
||||
c.hass = hass;
|
||||
c._serverStorage = true;
|
||||
c._layout = JSON.parse(JSON.stringify(layout));
|
||||
c._layoutRev = rev;
|
||||
c._unsubLayout = await hass.connection.subscribeEvents(
|
||||
(e) => c._onLayoutEvent(Number(e?.data?.rev ?? -1)),
|
||||
'houseplan_layout_updated',
|
||||
);
|
||||
out.subscribed = subs.length === 1;
|
||||
|
||||
// 1) чужая карточка подвинула иконку — наша обязана подхватить без перезагрузки
|
||||
layout = { ...layout, dev_a: { x: 77, y: 88 } }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
out.adoptedRemoteMove = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 77, y: 88 });
|
||||
out.revFollowed = c._layoutRev === rev;
|
||||
|
||||
// 2) собственная запись не вызывает лишнего перечитывания
|
||||
const before = gets;
|
||||
c._layout = { ...c._layout, dev_b: { x: 31, y: 32 } };
|
||||
c._dirtyPos.add('dev_b');
|
||||
c._persistLayout();
|
||||
c._persistLayout.flush();
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
out.ownWriteNoReload = gets === before;
|
||||
out.ownWriteKept = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 31, y: 32 });
|
||||
|
||||
// 3) НАСТОЯЩЕЕ перетаскивание: debounce запланирован, запись задержана, а
|
||||
// layout/get отвечает мгновенно. Именно этот порядок и терял позицию:
|
||||
// flush() внутри перечитывания опустошал _dirtyPos ДО снятия снимка.
|
||||
let releaseUpdate;
|
||||
const updateGate = new Promise((r) => { releaseUpdate = r; });
|
||||
let delayUpdate = true;
|
||||
const plain = hass.callWS;
|
||||
c.hass = { ...hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/layout/update' && delayUpdate) {
|
||||
delayUpdate = false;
|
||||
await updateGate;
|
||||
}
|
||||
return plain(m);
|
||||
} };
|
||||
|
||||
c._layout = { ...c._layout, dev_a: { x: 5, y: 6 } };
|
||||
c._dirtyPos.add('dev_a');
|
||||
c._persistLayout(); // debounce запланирован, не сброшен вручную
|
||||
layout = { ...layout, dev_b: { x: 99, y: 99 } }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
out.dragKeptWhileWriteInFlight = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||
out.remoteChangeApplied = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 99, y: 99 });
|
||||
|
||||
releaseUpdate();
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
out.localDragSurvived = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||
out.serverAgrees = JSON.stringify(layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||
out.sentPosDrained = c._sentPos.size === 0;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.1 и сверено с кодом
|
||||
checkAll(res, {
|
||||
subscribed: true,
|
||||
adoptedRemoteMove: true,
|
||||
revFollowed: true,
|
||||
ownWriteNoReload: true,
|
||||
ownWriteKept: true,
|
||||
dragKeptWhileWriteInFlight: true,
|
||||
remoteChangeApplied: true,
|
||||
localDragSurvived: true,
|
||||
serverAgrees: true,
|
||||
sentPosDrained: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,65 @@
|
||||
// HP-1503-01: один и тот же повреждённый store обязан рендериться одинаково в
|
||||
// ОБЕИХ карточках. Полная карточка строила модель рукописной копией
|
||||
// spaceModels и прошла мимо safeViewBox/normRect: статическая показывала
|
||||
// восстановленный план, полная — viewBox="0 0 0 0" и rect с отрицательной
|
||||
// шириной. Вектор аудита исполняется через обе модели и оба DOM-дерева.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const main = window.__card;
|
||||
|
||||
// ровно вектор из отчёта: нулевой viewport + отрицательный legacy-rect
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1.plan_url = null; f1.plan_aspect = null;
|
||||
f1.view_box = [0, 0, 0, 0];
|
||||
f1.rooms = [{ id: 'r1', name: 'R', area: 'living_room', x: 0.6, y: 0.7, w: -0.2, h: -0.3 }];
|
||||
f1.settings = { ...(f1.settings || {}), show_borders: true };
|
||||
const hass = { ...main.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
|
||||
return { ok: true };
|
||||
} };
|
||||
|
||||
main._serverCfg = cfg;
|
||||
main._cfgEpoch++;
|
||||
main._view = null;
|
||||
main.requestUpdate(); await main.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
|
||||
// модель полной карточки: fallback на весь холст + нормализованный rect
|
||||
const m = main._spaceModel('f1');
|
||||
out.fullVbFallsBack = JSON.stringify(m.vb) === JSON.stringify([0, 0, 1000, 1000]);
|
||||
const r = m.rooms[0];
|
||||
out.fullRectNormalised =
|
||||
Math.round(r.x) === 400 && Math.round(r.y) === 400
|
||||
&& Math.round(r.w) === 200 && Math.round(r.h) === 300;
|
||||
|
||||
// DOM полной карточки: конечный положительный viewBox, никаких минусов в rect
|
||||
const svg = (main.shadowRoot || main.renderRoot).querySelector('.stage svg');
|
||||
const vbAttr = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
||||
out.fullDomViewBoxSane = vbAttr.length === 4 && vbAttr[2] > 0 && vbAttr[3] > 0;
|
||||
const rect = (main.shadowRoot || main.renderRoot).querySelector('.stage svg rect.room, .stage svg .room rect, .stage svg rect[width]');
|
||||
out.fullDomRectSane = !rect || (Number(rect.getAttribute('width')) >= 0 && Number(rect.getAttribute('height')) >= 0);
|
||||
|
||||
// статическая карточка того же store — паритет
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('svg') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r2) => setTimeout(r2, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const svb = (card.renderRoot.querySelector('svg')?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
||||
out.staticDomViewBoxSane = svb.length === 4 && svb[2] > 0 && svb[3] > 0;
|
||||
out.parity = JSON.stringify(vbAttr.length === 4 && svb.length === 4
|
||||
? [vbAttr[2] > 0, vbAttr[3] > 0] : null) === JSON.stringify([svb[2] > 0, svb[3] > 0]);
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -0,0 +1,78 @@
|
||||
// Правило владельца (2026-07-29): у источников света подложка в glow-режиме
|
||||
// всегда стандартная — индикатор включения это ПЯТНО; в остальных режимах
|
||||
// горящий источник жёлтый, как греющая термоголовка. RGB лампы красит только
|
||||
// пятно (и фолбэк цвета пульсации); окраска иконки/рамки убрана. Морф иконки
|
||||
// остаётся везде. Розетка в glow-режиме остаётся жёлтой — она не источник.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const setFill = async (mode) => {
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1.settings = { ...(f1.settings || {}), fill_mode: mode };
|
||||
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
};
|
||||
const lamp = c._devices.find((x) => x.id === 'd_lamp');
|
||||
const kettle = c._devices.find((x) => x.id === 'd_kettle'); // розетка/чайник
|
||||
// зажигаем лампу с RGB и розетку
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
[lamp.primary]: { ...c.hass.states[lamp.primary], state: 'on',
|
||||
attributes: { ...(c.hass.states[lamp.primary]?.attributes || {}), rgb_color: [255, 120, 40] } },
|
||||
[kettle.primary]: { ...c.hass.states[kettle.primary], state: 'on' } } };
|
||||
const devEl = (id) => {
|
||||
// надёжнее по индексу в списке devs: ищем по позиции через _pos
|
||||
const d = c._devices.find((x) => x.id === id);
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(d);
|
||||
const left = (((p.x - v.x) / v.w) * 100).toFixed(0);
|
||||
return [...sr().querySelectorAll('.dev')].find((e) => Math.abs(parseFloat(e.style.left) - left) < 1.5);
|
||||
};
|
||||
|
||||
// --- режим light: горящая RGB-лампа ЖЁЛТАЯ, без rgb-класса --------------
|
||||
await setFill('light');
|
||||
let le = devEl('d_lamp');
|
||||
o.litLampYellowInLightMode = !!le && le.classList.contains('on') && !le.classList.contains('rgb');
|
||||
o.morphInLightMode = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
|
||||
|
||||
// --- режим glow: лампа ТЁМНАЯ (индикатор — пятно), розетка жёлтая -------
|
||||
await setFill('glow');
|
||||
le = devEl('d_lamp');
|
||||
const ke = devEl('d_kettle');
|
||||
o.litLampDarkInGlow = !!le && !le.classList.contains('on') && !le.classList.contains('rgb');
|
||||
o.glowSpotPresent = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
|
||||
o.socketStaysYellowInGlow = !!ke && ke.classList.contains('on');
|
||||
o.morphInGlow = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
|
||||
|
||||
// --- HP-1520-01: в редакторах, где glow-слой скрыт, индикатор возвращается
|
||||
// (лампа горит; в devices-редакторе слой рисуется — там лампа тёмная,
|
||||
// в plan-редакторе слоя нет — там класс on должен вернуться)
|
||||
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
le = devEl('d_lamp');
|
||||
o.devicesEditorLampDark = !!le && !le.classList.contains('on');
|
||||
o.devicesEditorHasGlow = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
|
||||
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
|
||||
// ИМЕННО лампа (mutation-проверка аудита HP-1521-01: розетка тоже on и
|
||||
// удовлетворяла бы поиску «любой .dev.on», пряча регресс самой лампы)
|
||||
le = devEl('d_lamp');
|
||||
o.planEditorLampYellow = !!le && le.classList.contains('on'); // слой скрыт — жёлтый вернулся
|
||||
o.planEditorKettleStillOn = !!devEl('d_kettle')?.classList.contains('on');
|
||||
o.planEditorNoGlow = !sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
|
||||
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// --- выключенная лампа тёмная в обоих режимах ---------------------------
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
[lamp.primary]: { ...c.hass.states[lamp.primary], state: 'off' } } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
le = devEl('d_lamp');
|
||||
o.offLampDarkInGlow = !!le && !le.classList.contains('on');
|
||||
await setFill('light');
|
||||
le = devEl('d_lamp');
|
||||
o.offLampDarkInLightMode = !!le && !le.classList.contains('on');
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -38,8 +38,7 @@ const res = await page.evaluate(async () => {
|
||||
const vid = c._serverCfg.markers.find((m) => m.name === 'Тест')?.id;
|
||||
const center = c._roomCenter(room);
|
||||
const vpos = c._layout[vid];
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
|
||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * 1000 - center[1]) < 1;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
@@ -9,7 +9,7 @@ const restore = () => page.evaluate((s) => {
|
||||
}, snap);
|
||||
// norm→render helper mirrors what _markupClick passes to handlers
|
||||
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||
return [nx * 1000, ny * 1000]; // the canvas is square (v1.48.0)
|
||||
}, [nx, ny]);
|
||||
const S = () => page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
|
||||
@@ -13,7 +13,8 @@ const st = () => page.evaluate(() => {
|
||||
editBtns: sr.querySelectorAll('.head .btn:not(.zb)').length,
|
||||
gears: sr.querySelectorAll('.tabedit').length,
|
||||
markupBar: !!sr.querySelector('.editbar'),
|
||||
stageClass: sr.querySelector('.stage').className,
|
||||
// hpsettle is the transient post-boot grace (AUD-1552-02), not a mode class
|
||||
stageClass: sr.querySelector('.stage').className.replace(/ ?\bhpsettle\b/, ''),
|
||||
};
|
||||
});
|
||||
// 1) старт: view, чистая шапка
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Правило владельца (2026-08-01, вариант «б»): «движение = разовая вспышка
|
||||
// в момент обнаружения; cool-down не пульсирует; присутствие = статичное
|
||||
// кольцо пока обитаемо». Датчик ДВИЖЕНИЯ (device_class motion) при переходе
|
||||
// off→on получает класс 'senseflash' — короткая вспышка кольца (hp-sense,
|
||||
// 3 удара, ~3.3 c), после чего класс снимается, ДАЖЕ если сущность всё ещё
|
||||
// висит в on (это cool-down датчика, а не движение). Новый цикл off→on —
|
||||
// новая вспышка. Датчики ПРИСУТСТВИЯ (occupancy/presence) при on несут
|
||||
// 'sensehold' — статичное жёлтое кольцо без анимации (opacity 0.4). Жёлтая
|
||||
// заливка .dev.on в обоих случаях не участвует; ghost — без кольца
|
||||
// (призрак — конфигурация, не статус).
|
||||
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 setMotion = async (state, device_class) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state,
|
||||
attributes: { friendly_name: 'Motion', device_class, linkquality: 64 } } } };
|
||||
await c.updateComplete;
|
||||
};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// --- motion off→on: вспышка senseflash, 3 удара, подложка НЕ жёлтая -----
|
||||
await setMotion('off', 'motion');
|
||||
out.offNoFlash = sr().querySelectorAll('.dev.senseflash, .dev.sensehold').length === 0;
|
||||
await setMotion('on', 'motion');
|
||||
const flashEl = sr().querySelector('.dev.senseflash');
|
||||
out.motionTripFlashes = !!flashEl;
|
||||
out.flashIsNotOn = !!flashEl && !flashEl.classList.contains('on') && !flashEl.classList.contains('open');
|
||||
const after = flashEl ? getComputedStyle(flashEl, '::after') : null;
|
||||
out.flashAnimated = !!after && after.animationName === 'hp-sense';
|
||||
out.flashFinite = !!after && after.animationIterationCount === '3'; // не infinite!
|
||||
// подложка нейтральная — как у обычного .dev, не как у .dev.on
|
||||
const neutral = [...sr().querySelectorAll('.dev')].find((e) =>
|
||||
!e.classList.contains('senseflash') && !e.classList.contains('sensehold')
|
||||
&& !e.classList.contains('on') && !e.classList.contains('open')
|
||||
&& !e.classList.contains('alarm') && !e.classList.contains('unavail') && !e.classList.contains('valonly'));
|
||||
const bg = flashEl ? getComputedStyle(flashEl).backgroundColor : '';
|
||||
out.flashBgNeutral = !!neutral && bg === getComputedStyle(neutral).backgroundColor;
|
||||
out.flashBgNotYellow = bg !== '' && bg !== 'rgb(255, 212, 92)'; // --hp-on
|
||||
|
||||
// --- КЛЮЧЕВОЙ ассерт: вспышка гаснет, хотя state всё ещё on -------------
|
||||
// (cool-down датчика — не движение: класс обязан уйти сам по таймеру)
|
||||
await sleep(3600);
|
||||
await c.updateComplete;
|
||||
out.stateStillOn = c.hass.states['binary_sensor.hall_motion'].state === 'on';
|
||||
out.flashEndsWhileOn = sr().querySelectorAll('.dev.senseflash').length === 0;
|
||||
|
||||
// --- повторный цикл off→on: новая вспышка --------------------------------
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion');
|
||||
out.retripFlashesAgain = sr().querySelectorAll('.dev.senseflash').length === 1;
|
||||
|
||||
// --- HP-1543-02: быстрый повторный off→on ДО конца текущей вспышки ------
|
||||
// обязан ПЕРЕЗАПУСТИТЬ анимацию (новый timeline). Раньше класс senseflash
|
||||
// не снимался и animation-name не менялся — браузер продолжал старый
|
||||
// timeline, и после его конца (base opacity 0) второй trip был невидим.
|
||||
await sleep(3600); await c.updateComplete; // дать текущей вспышке догореть
|
||||
const senseAnim = (el) => el && el.getAnimations({ subtree: true })
|
||||
.find((a) => a.animationName && a.animationName.startsWith('hp-sense'));
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion'); // trip №1
|
||||
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.senseflash').length === 1;
|
||||
await sleep(1200); // середина первой вспышки (< 3.3s)
|
||||
const a1 = senseAnim(sr().querySelector('.dev.senseflash'));
|
||||
out.rapidFirstAnimMidway = !!a1 && a1.currentTime > 900 && a1.currentTime < 3300;
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion'); // trip №2 — retrip до конца вспышки
|
||||
out.rapidRetripKeepsFlash = sr().querySelectorAll('.dev.senseflash').length === 1;
|
||||
const a2 = senseAnim(sr().querySelector('.dev.senseflash'));
|
||||
// новый timeline: currentTime у начала (старый уже был ~1.2s и продолжал бы расти)
|
||||
out.rapidRetripRestartsAnim = !!a2 && a2.currentTime != null && a2.currentTime < 300;
|
||||
// кольцо реально играет ПОСЛЕ момента, где закончилась бы первая анимация:
|
||||
// ~2.3s после trip №2 = ~3.5s после trip №1 (старый timeline уже отыграл бы)
|
||||
await sleep(2300);
|
||||
const el3 = sr().querySelector('.dev.senseflash');
|
||||
const a3 = senseAnim(el3);
|
||||
out.rapidSecondFlashStillPlays = !!a3 && a3.playState === 'running';
|
||||
out.rapidRingVisibleAfterFirstWindow = !!el3
|
||||
&& parseFloat(getComputedStyle(el3, '::after').opacity) > 0.03;
|
||||
// и класс уходит через полные ~3.3s после ВТОРОГО trip (flashTs/таймер обновлены)
|
||||
await sleep(1400); await c.updateComplete;
|
||||
out.rapidFlashEndsAfterSecondWindow = sr().querySelectorAll('.dev.senseflash').length === 0;
|
||||
|
||||
// --- occupancy: статичное кольцо sensehold, без анимации ----------------
|
||||
await setMotion('on', 'occupancy');
|
||||
const holdEl = sr().querySelector('.dev.sensehold');
|
||||
out.occupancyHolds = !!holdEl;
|
||||
const hAfter = holdEl ? getComputedStyle(holdEl, '::after') : null;
|
||||
out.holdNotAnimated = !!hAfter && hAfter.animationName === 'none';
|
||||
out.holdRingFaint = !!hAfter && Math.abs(parseFloat(hAfter.opacity) - 0.4) < 0.01;
|
||||
out.holdBgNeutral = !!holdEl && !!neutral
|
||||
&& getComputedStyle(holdEl).backgroundColor === getComputedStyle(neutral).backgroundColor;
|
||||
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('senseflash');
|
||||
await setMotion('off', 'occupancy');
|
||||
out.occupancyOffClears = sr().querySelectorAll('.dev.sensehold').length === 0;
|
||||
|
||||
// --- presence аналогично -------------------------------------------------
|
||||
await setMotion('on', 'presence');
|
||||
out.presenceHolds = sr().querySelectorAll('.dev.sensehold').length === 1;
|
||||
// и это именно sense-состояние, не тревога и не «открыто»
|
||||
out.noAlarmNoOpen = !sr().querySelector('.dev.sensehold.alarm') && !sr().querySelector('.dev.sensehold.open');
|
||||
|
||||
// --- ghost не рисует ничего (призрак — конфигурация, не статус) ---------
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion');
|
||||
const d = c._devices.find((x) => x.entities.includes('binary_sensor.hall_motion'));
|
||||
c._serverCfg.markers = c._serverCfg.markers || [];
|
||||
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
out.hiddenNotInView = sr().querySelectorAll('.dev.senseflash, .dev.sensehold').length === 0;
|
||||
c._showHidden = true; c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
const ghost = sr().querySelector('.dev.ghost');
|
||||
out.ghostNoSense = !!ghost && !ghost.classList.contains('senseflash') && !ghost.classList.contains('sensehold')
|
||||
&& getComputedStyle(ghost, '::after').animationName !== 'hp-sense';
|
||||
return out;
|
||||
});
|
||||
checkAll(res, {});
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,276 @@
|
||||
// Opening drag rulers (owner 2026-08-03): while a door/window is dragged along
|
||||
// a wall, a measure badge sits on EACH shoulder (wall end -> opening edge,
|
||||
// along the wall), and when the opening's center hits the wall's center a
|
||||
// perpendicular dashed tick appears and the center magnet-snaps (Shift opts
|
||||
// out). The wall is ONE room's edge (owner: "only the wall of one room") —
|
||||
// r2's collinear top edge never merges into r1's, so the ruler runs 40..550
|
||||
// (center 295), not the old merged 40..960. Real pointer events; the numbers
|
||||
// are checked against the geometry: cell = 5 cm, grid pitch = 1000/240, so
|
||||
// cm = units * 1.2 and r1's top wall (510 u) is 6.12 m.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
// 80-unit door on r1's top wall (y = 0.14), off-center
|
||||
sp.openings = [{ id: 'op1', type: 'door', x: 0.2, y: 0.14, angle: 0, length: 0.08 }];
|
||||
c._setMode('plan'); c._tool = 'opening';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
await settle();
|
||||
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const c = window.__card;
|
||||
const stage = c.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const svg = stage.querySelector('svg');
|
||||
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
const badges = () => page.evaluate(() =>
|
||||
[...window.__card.renderRoot.querySelectorAll('.measurelabel.opshoulder')].map((el) => el.textContent.trim()).sort());
|
||||
const nums = async () => (await badges()).map((t) => parseFloat(t));
|
||||
const tick = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.opcentertick').length);
|
||||
const near = (a, b, tol) => Math.abs(a - b) <= tol;
|
||||
|
||||
// ---------- drag to an off-center spot: two live badges, no tick ----------
|
||||
const [sx, sy] = await screenPt(200, 140);
|
||||
const [t1x] = await screenPt(460, 140);
|
||||
await page.mouse.move(sx, sy);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(t1x, sy, { steps: 5 });
|
||||
await settle();
|
||||
// center 460 -> edges 420/500; shoulders measured on r1's OWN edge 40..550:
|
||||
// 420-40=380 u (4.56 m) and 550-500=50 u (0.60 m) — the old merged build
|
||||
// showed 960-500=460 u (5.52 m) on the right
|
||||
{
|
||||
const [a, b] = await nums();
|
||||
check('two_badges_visible', (await badges()).length, 2);
|
||||
check('badge_right_0_60', near(a, 0.6, 0.04));
|
||||
check('badge_left_4_56', near(b, 4.56, 0.04));
|
||||
// exact invariant, immune to pointer rounding: shoulders + opening = wall
|
||||
check('badges_sum_to_wall', near(a + b, 6.12 - 0.96, 0.021));
|
||||
}
|
||||
check('no_tick_off_center', await tick(), 0);
|
||||
|
||||
// ---------- live update on the way ----------
|
||||
const [t2x] = await screenPt(360, 140);
|
||||
await page.mouse.move(t2x, sy, { steps: 3 });
|
||||
await settle();
|
||||
// center 360 -> shoulders 320-40=280 u (3.36 m) and 550-400=150 u (1.80 m)
|
||||
{
|
||||
const [a, b] = await nums();
|
||||
check('badge_right_updates_1_80', near(a, 1.8, 0.04));
|
||||
check('badge_left_updates_3_36', near(b, 3.36, 0.04));
|
||||
}
|
||||
|
||||
// ---------- near the OWN edge center (40+550)/2 = 295: tick + magnet ----------
|
||||
const [t3x] = await screenPt(293.7, 140);
|
||||
await page.mouse.move(t3x, sy, { steps: 3 });
|
||||
await settle();
|
||||
check('tick_at_center', await tick(), 1);
|
||||
check('magnet_snaps_center', near(await page.evaluate(() =>
|
||||
window.__card._curSpaceCfg.openings[0].x), 0.295, 1e-6));
|
||||
// both shoulders equal after the snap: (510 - 80) / 2 = 215 u -> 2.58 m
|
||||
check('badges_equal_at_center', await badges(), ['2.58 m', '2.58 m']);
|
||||
// the tick is perpendicular to the wall (horizontal wall -> a vertical dash)
|
||||
const geom = await page.evaluate(() => {
|
||||
const el = window.__card.renderRoot.querySelector('.opcentertick');
|
||||
if (!el) return null;
|
||||
const dashed = getComputedStyle(el).strokeDasharray;
|
||||
return { x1: +el.getAttribute('x1'), x2: +el.getAttribute('x2'),
|
||||
y1: +el.getAttribute('y1'), y2: +el.getAttribute('y2'),
|
||||
dashed: dashed !== 'none' && dashed !== '' };
|
||||
});
|
||||
check('tick_perpendicular_at_wall_center', !!geom
|
||||
&& near(geom.x1, 295, 1e-6) && near(geom.x2, 295, 1e-6) && near(geom.y2 - geom.y1, 30, 1e-6));
|
||||
check('tick_dashed', !!geom && geom.dashed);
|
||||
|
||||
// ---------- release: everything disappears, the snapped x is committed ----------
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
check('badges_gone_after_drop', (await badges()).length, 0);
|
||||
check('tick_gone_after_drop', await tick(), 0);
|
||||
check('committed_x_center', near(await page.evaluate(() =>
|
||||
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
|
||||
|
||||
// ---------- Shift disables the magnet (badges stay) ----------
|
||||
// park the opening off-center first: a drag that returns to within 3 px of its
|
||||
// own starting point is filtered by the tap threshold, so the Shift approach
|
||||
// must start away from the center
|
||||
const [cx0] = await screenPt(295, 140);
|
||||
await page.mouse.move(cx0, sy);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(t2x, sy, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
await page.keyboard.down('Shift');
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(t3x, sy, { steps: 3 });
|
||||
await settle();
|
||||
check('no_tick_with_shift', await tick(), 0);
|
||||
const freeX = await page.evaluate(() => window.__card._curSpaceCfg.openings[0].x);
|
||||
check('no_magnet_with_shift', near(freeX, 0.2937, 0.004) && freeX !== 0.295);
|
||||
check('badges_still_there_with_shift', (await badges()).length, 2);
|
||||
await page.mouse.up();
|
||||
await page.keyboard.up('Shift');
|
||||
await settle();
|
||||
check('badges_gone_final', (await badges()).length, 0);
|
||||
|
||||
// ---------- opening on a SHARED wall of two rooms: the own room's edge ----------
|
||||
// staggered fragments on the boundary x=550: r1's right edge 140..460 (its
|
||||
// room is made the short one), r2's left edge 140..580. The snap resolves to
|
||||
// r1's edge (nearest, first in roomEdges order on the tie), so the shoulders
|
||||
// and the center come from 140..460 — the old build measured the merged
|
||||
// 140..580 union
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.rooms = [
|
||||
{ id: 'r1', name: 'Living room', poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.46], [0.04, 0.46]] },
|
||||
{ id: 'r2', name: 'Kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.58], [0.55, 0.58]] },
|
||||
];
|
||||
sp.openings = [{ id: 'op1', type: 'door', x: 0.55, y: 0.3, angle: 90, length: 0.08 }];
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
await settle();
|
||||
const [vx0, vy0] = await screenPt(550, 300);
|
||||
const [, vy1] = await screenPt(550, 360);
|
||||
await page.mouse.move(vx0, vy0);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(vx0, vy1, { steps: 3 });
|
||||
await settle();
|
||||
// center 360 -> edges 320/400; r1's edge 140..460: 320-140=180 u (2.16 m) and
|
||||
// 460-400=60 u (0.72 m); the merged build showed 580-400=180 u (2.16 m) below
|
||||
{
|
||||
const [a, b] = await nums();
|
||||
check('shared_wall_two_badges', (await badges()).length, 2);
|
||||
check('shared_wall_bottom_0_72', near(a, 0.72, 0.04));
|
||||
check('shared_wall_top_2_16', near(b, 2.16, 0.04));
|
||||
}
|
||||
check('shared_wall_no_tick_off_center', await tick(), 0);
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
// approach the OWN edge center (140+460)/2 = 300 — NOT the merged 360
|
||||
const [, vy2] = await screenPt(550, 301.3);
|
||||
await page.mouse.move(vx0, vy1);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(vx0, vy2, { steps: 3 });
|
||||
await settle();
|
||||
check('shared_wall_tick_at_own_center', await tick(), 1);
|
||||
check('shared_wall_magnet_y_0_3', near(await page.evaluate(() =>
|
||||
window.__card._curSpaceCfg.openings[0].y), 0.3, 1e-6));
|
||||
// equal shoulders on the own edge: (320 - 80) / 2 = 120 u -> 1.44 m
|
||||
check('shared_wall_badges_equal', await badges(), ['1.44 m', '1.44 m']);
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
|
||||
// ---------- PLACING a new opening: the same rulers (owner 2026-08-03) -------
|
||||
// Moving along a wall with the Opening tool must show exactly what dragging an
|
||||
// existing opening shows: a badge on each shoulder of the WOULD-BE opening, a
|
||||
// perpendicular tick + magnet at the wall centre, Shift opting out — and the
|
||||
// whole lot vanishing the moment the opening is placed. Default length is
|
||||
// 90 cm = 75 units, so the shoulders are measured from the edges c +- 37.5.
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.rooms = [
|
||||
{ id: 'r1', name: 'Living room', poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
|
||||
{ id: 'r2', name: 'Kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] },
|
||||
];
|
||||
sp.openings = [];
|
||||
c._setMode('plan'); c._tool = 'opening';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
await settle();
|
||||
const ghosts = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.opghost').length);
|
||||
|
||||
// hover off-centre on r1's top wall (own edge 40..550, y = 140)
|
||||
{
|
||||
const [hx, hy] = await screenPt(460, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
check('place_ghost_shown', await ghosts(), 1);
|
||||
check('place_two_badges', (await badges()).length, 2);
|
||||
// edges 422.5 / 497.5 -> 382.5 u (4.59 m) left, 52.5 u (0.63 m) right
|
||||
const [a, b] = await nums();
|
||||
check('place_badge_right_0_63', near(a, 0.63, 0.05));
|
||||
check('place_badge_left_4_59', near(b, 4.59, 0.05));
|
||||
check('place_no_tick_off_center', await tick(), 0);
|
||||
}
|
||||
|
||||
// live update on the way — the badges follow the cursor
|
||||
{
|
||||
const [hx, hy] = await screenPt(200, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
// edges 162.5 / 237.5 -> 122.5 u (1.47 m) left, 312.5 u (3.75 m) right
|
||||
const [a, b] = await nums();
|
||||
check('place_badge_updates_1_47', near(a, 1.47, 0.05));
|
||||
check('place_badge_updates_3_75', near(b, 3.75, 0.05));
|
||||
}
|
||||
|
||||
// near the wall centre (40+550)/2 = 295: tick appears and the magnet bites
|
||||
{
|
||||
const [hx, hy] = await screenPt(293.7, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
check('place_tick_at_center', await tick(), 1);
|
||||
// equal shoulders after the magnet: (510 - 75) / 2 = 217.5 u -> 2.61 m
|
||||
check('place_badges_equal_at_center', await badges(), ['2.61 m', '2.61 m']);
|
||||
const g = await page.evaluate(() => {
|
||||
const el = window.__card.renderRoot.querySelector('.opcentertick');
|
||||
return el && { x1: +el.getAttribute('x1'), x2: +el.getAttribute('x2'),
|
||||
y1: +el.getAttribute('y1'), y2: +el.getAttribute('y2') };
|
||||
});
|
||||
check('place_tick_perpendicular', !!g && near(g.x1, 295, 1e-6) && near(g.x2, 295, 1e-6)
|
||||
&& near(g.y2 - g.y1, 30, 1e-6));
|
||||
// the ghost itself already sits on the magnetised centre
|
||||
const gh = await page.evaluate(() => {
|
||||
const el = window.__card.renderRoot.querySelector('.opghost');
|
||||
return el && (+el.getAttribute('x1') + +el.getAttribute('x2')) / 2;
|
||||
});
|
||||
check('place_ghost_magnetised', near(gh, 295, 1e-6));
|
||||
}
|
||||
|
||||
// Shift opts out of the magnet, the badges stay
|
||||
{
|
||||
await page.keyboard.down('Shift');
|
||||
const [hx, hy] = await screenPt(293.7, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
check('place_no_tick_with_shift', await tick(), 0);
|
||||
check('place_badges_with_shift', (await badges()).length, 2);
|
||||
const gh = await page.evaluate(() => {
|
||||
const el = window.__card.renderRoot.querySelector('.opghost');
|
||||
return el && (+el.getAttribute('x1') + +el.getAttribute('x2')) / 2;
|
||||
});
|
||||
check('place_ghost_free_with_shift', !!gh && !near(gh, 295, 1e-6) && near(gh, 293.7, 1));
|
||||
await page.keyboard.up('Shift');
|
||||
}
|
||||
|
||||
// the click places it AT THE MAGNET and clears every hint
|
||||
{
|
||||
const [hx, hy] = await screenPt(293.7, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.click(hx, hy);
|
||||
await settle();
|
||||
check('place_dialog_opened', await page.evaluate(() => !!window.__card._openingDialog), true);
|
||||
check('place_dialog_x_magnetised', near(await page.evaluate(() => window.__card._openingDialog.x), 295, 1e-6));
|
||||
check('place_badges_gone', (await badges()).length, 0);
|
||||
check('place_tick_gone', await tick(), 0);
|
||||
check('place_ghost_gone', await ghosts(), 0);
|
||||
// …and confirming the dialog writes the opening at the magnetised point
|
||||
await page.evaluate(() => { const c = window.__card; c._saveOpening(); return c.updateComplete && true; });
|
||||
await settle();
|
||||
check('place_committed_x_center', near(await page.evaluate(() =>
|
||||
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
|
||||
}
|
||||
|
||||
await finish(browser, { done: true });
|
||||
@@ -6,7 +6,7 @@ const res = await page.evaluate(async () => {
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||
// r1|r2 делят стену x=0.55 → клик по ней открывает границу
|
||||
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const H = 1000; // square canvas
|
||||
c._openWallClick([550, 0.25 * H]);
|
||||
await c.updateComplete;
|
||||
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
|
||||
@@ -53,8 +53,7 @@ const res = await page.evaluate(async () => {
|
||||
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
|
||||
const litLight = c._devices.find((d) => d.space === c._space && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
|
||||
const c1 = c._roomCenter(c._spaceModel().rooms.find((r) => r.id === 'r1'));
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / 1000 } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
|
||||
out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false;
|
||||
|
||||
@@ -6,7 +6,7 @@ const res = await page.evaluate(async () => {
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const H = 1000; // square canvas
|
||||
// 1) без наведения: курсор default, превью нет
|
||||
c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.idleCursor = getComputedStyle(stage()).cursor === 'default';
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Panning at ANY zoom (owner's report 2026-08-04: «добавь возможность таскать
|
||||
// план при любом масштабе, а не только при более 100%»).
|
||||
//
|
||||
// Until this fix `_stagePointerMove` moved the view only while `_zoom > 1`:
|
||||
// the rule was inherited from the old bounded canvas, where a plan smaller
|
||||
// than the scene had nowhere to go. The infinite canvas removed that edge —
|
||||
// there is always somewhere to go, and an editor extending a plan outwards
|
||||
// needs exactly that. So a drag on empty scene now pans at 100 %, at 50 % and
|
||||
// at 800 %, in view mode and in every editor; how far you may walk is decided
|
||||
// by `_clampView` (PAN_SLACK: about a screen past the content) and not by the
|
||||
// zoom. The tools keep the pointer they already owned — a resize handle, a
|
||||
// device badge, an opening — and the kiosk keeps its floor swipe.
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
// ---- a drag on empty scene pans, at every zoom and in every mode ---------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
let pid = 100;
|
||||
/** Drag 80x60 px across the middle of the scene; did the view follow? */
|
||||
const drag = async (zoom) => {
|
||||
// always start from the plan in the middle: the clamp is a real limit, and
|
||||
// a dozen drags in a row would otherwise walk into it
|
||||
const vb = c._baseVb();
|
||||
c._applyView(zoom, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||
await c.updateComplete;
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const id = ++pid;
|
||||
pd(id, 300, 300);
|
||||
pm(id, 340, 330);
|
||||
pm(id, 380, 360);
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
pu(id, 380, 360);
|
||||
// dragging down-right pulls the viewport up-left, and the zoom is untouched
|
||||
return Math.abs(v1.x - v0.x) > 1 && Math.abs(v1.y - v0.y) > 1
|
||||
&& v1.x < v0.x && v1.y < v0.y && Math.abs(c._zoom - zoom) < 1e-9;
|
||||
};
|
||||
const inMode = async (name, setup) => {
|
||||
await setup();
|
||||
await c.updateComplete;
|
||||
o['pan_' + name + '_at100'] = await drag(1);
|
||||
o['pan_' + name + '_at50'] = await drag(0.5);
|
||||
o['pan_' + name + '_at33'] = await drag(1 / 3);
|
||||
};
|
||||
|
||||
await inMode('view', async () => { c._setMode('view'); });
|
||||
for (const tool of ['draw', 'merge', 'split', 'resize', 'opening', 'openwall', 'delroom']) {
|
||||
await inMode('plan_' + tool, async () => { c._setMode('plan'); c._tool = tool; });
|
||||
}
|
||||
await inMode('devices', async () => { c._setMode('devices'); });
|
||||
await inMode('decor', async () => { c._setMode('decor'); c._decorTool = 'select'; });
|
||||
c._setMode('view');
|
||||
c._applyView(1);
|
||||
await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- 400 % still pans (the behaviour that already worked) ----------------
|
||||
out.panStillWorksZoomedIn = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('view'); c._applyView(4); await c.updateComplete;
|
||||
const v0 = { ...c._view };
|
||||
c._stagePointerDown({ pointerId: 7, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 7, clientX: 380, clientY: 360 });
|
||||
const moved = Math.abs(c._view.x - v0.x) > 1;
|
||||
c._stagePointerUp({ pointerId: 7, clientX: 380, clientY: 360 });
|
||||
c._applyView(1);
|
||||
return moved;
|
||||
});
|
||||
|
||||
// ---- the clamp: you may walk about a screen off, never further ------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
c._setMode('view');
|
||||
const vb = c._baseVb();
|
||||
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); // at 100 % the view IS fitView
|
||||
await c.updateComplete;
|
||||
const fit = { ...c._view };
|
||||
c._stagePointerDown({ pointerId: 8, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 8, clientX: -40000, clientY: 400 }); // shove it into the void
|
||||
const far = { ...c._view };
|
||||
c._stagePointerUp({ pointerId: 8, clientX: -40000, clientY: 400 });
|
||||
o.panLeavesTheContent = far.x > fit.x + fit.w * 0.5;
|
||||
o.panStopsAtTheSlack = far.x <= fit.x + fit.w + 1;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
o.homeArrowOffersTheWayBack = !!sr.querySelector('.homearrow');
|
||||
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
// the same at 50 %: the screen holds more, the walk is still finite
|
||||
const fit2 = { ...c._view };
|
||||
c._applyView(0.5, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
|
||||
c._stagePointerDown({ pointerId: 9, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 9, clientX: -40000, clientY: 400 });
|
||||
const far2 = { ...c._view };
|
||||
c._stagePointerUp({ pointerId: 9, clientX: -40000, clientY: 400 });
|
||||
o.panStopsAtTheSlackZoomedOut = far2.x <= fit2.x + fit2.w + 1 && far2.x > fit2.x;
|
||||
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- the tools still own their pointer ------------------------------------
|
||||
const ev = (type, id, x, y) => ({ type, id, x, y });
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
const fire = (el, type, id, x, y) => el.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
|
||||
}));
|
||||
|
||||
// a) resize handle: the wall moves, the view does not
|
||||
c._setMode('plan'); c._tool = 'resize'; c._applyView(1); await c.updateComplete;
|
||||
const h = sr.querySelector('.rszhandle');
|
||||
o.resizeHandlePresent = !!h;
|
||||
if (h) {
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const r = h.getBoundingClientRect();
|
||||
fire(h, 'pointerdown', 21, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.resizeHandleTakesThePointer = !!c._rszDrag && c._panStart === null;
|
||||
fire(h, 'pointermove', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
o.resizeHandleDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
|
||||
o.resizeHandleResizes = !!c._rszDrag && c._rszDrag.moved === true;
|
||||
c._rszCancelDrag?.();
|
||||
fire(h, 'pointerup', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
|
||||
}
|
||||
c._rszSel = null;
|
||||
|
||||
// b) a device badge in the devices editor drags the DEVICE
|
||||
c._setMode('devices'); c._applyView(1); await c.updateComplete;
|
||||
const dev = sr.querySelector('.devlayer .dev');
|
||||
o.devicePresent = !!dev;
|
||||
if (dev) {
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const r = dev.getBoundingClientRect();
|
||||
fire(dev, 'pointerdown', 22, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.deviceTakesThePointer = !!c._drag && c._panStart === null;
|
||||
fire(dev, 'pointermove', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
|
||||
c._stagePointerMove({ pointerId: 22, clientX: r.left + 60, clientY: r.top + 50 });
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
o.deviceDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
|
||||
o.deviceActuallyMoved = !!c._drag && c._drag.moved === true;
|
||||
fire(dev, 'pointerup', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
|
||||
// _pointerUp releases _drag on the next macrotask, and _stagePointerDown
|
||||
// refuses to start a gesture while a device drag is live
|
||||
await new Promise((r2) => setTimeout(r2, 20));
|
||||
o.deviceDragReleased = c._drag === null;
|
||||
await c.updateComplete;
|
||||
}
|
||||
|
||||
// c) an opening drags along its wall, the view stays put
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].openings = [{ id: 'o_pan', type: 'window', x: 0.3, y: 0.14, angle: 0, length: 0.1 }];
|
||||
c._serverCfg = cfg; c._modelCache = null; c._frame = null;
|
||||
c._setMode('plan'); c._tool = 'draw'; c._applyView(1);
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const op = sr.querySelector('.op-hit');
|
||||
o.openingPresent = !!op;
|
||||
if (op) {
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const r = op.getBoundingClientRect();
|
||||
fire(op, 'pointerdown', 23, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.openingTakesThePointer = !!c._opDrag && c._panStart === null;
|
||||
fire(op, 'pointermove', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
o.openingDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
|
||||
fire(op, 'pointerup', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
|
||||
await c.updateComplete;
|
||||
}
|
||||
|
||||
// d) a drawing tool is still a drawing tool: two fingers pinch, they do not pan
|
||||
c._setMode('plan'); c._tool = 'draw'; c._applyView(1); await c.updateComplete;
|
||||
const z0 = c._zoom;
|
||||
c._stagePointerDown({ pointerId: 31, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerDown({ pointerId: 32, clientX: 400, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 31, clientX: 250, clientY: 300 });
|
||||
c._stagePointerMove({ pointerId: 32, clientX: 450, clientY: 300 });
|
||||
o.pinchStillZooms = c._zoom > z0 * 1.5;
|
||||
c._stagePointerUp({ pointerId: 31, clientX: 250, clientY: 300 });
|
||||
c._stagePointerUp({ pointerId: 32, clientX: 450, clientY: 300 });
|
||||
c._path = [];
|
||||
c._setMode('view'); c._applyView(1); await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- the kiosk keeps its floor swipe ---------------------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c0 = window.__card;
|
||||
const o = {};
|
||||
const k = document.createElement('houseplan-card');
|
||||
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
k.hass = c0.hass;
|
||||
document.body.appendChild(k);
|
||||
k.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
k.hass = { ...c0.hass };
|
||||
await k.updateComplete;
|
||||
const sr = k.shadowRoot || k.renderRoot;
|
||||
const stage = sr.querySelector('.stage');
|
||||
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
|
||||
}));
|
||||
k._applyView(1); await k.updateComplete;
|
||||
const s0 = k._space;
|
||||
const v0 = { ...k._viewOr(k._baseVb()) };
|
||||
// a REAL swipe: down, several moves, up — the moves are what used to be
|
||||
// impossible to add to smoke_kiosk without hijacking the gesture
|
||||
fire('pointerdown', 41, 600, 300);
|
||||
fire('pointermove', 41, 540, 302);
|
||||
fire('pointermove', 41, 480, 305);
|
||||
fire('pointermove', 41, 450, 305);
|
||||
const midPan = Math.abs(k._viewOr(k._baseVb()).x - v0.x);
|
||||
fire('pointerup', 41, 450, 305);
|
||||
await k.updateComplete;
|
||||
o.kioskSwipeStillSwitchesFloors = k._space !== s0;
|
||||
o.kioskHorizontalDragIsNotAPan = midPan < 1;
|
||||
// …and the plan is not left hanging half-panned after the switch
|
||||
o.kioskViewCenteredAfterSwipe = Math.abs(k._viewOr(k._baseVb()).x - v0.x) < 2000;
|
||||
// a VERTICAL drag is nobody's swipe, so it pans like everywhere else
|
||||
const s1 = k._space;
|
||||
const v1 = { ...k._viewOr(k._baseVb()) };
|
||||
fire('pointerdown', 42, 450, 200);
|
||||
fire('pointermove', 42, 452, 260);
|
||||
fire('pointermove', 42, 454, 330);
|
||||
const panned = Math.abs(k._viewOr(k._baseVb()).y - v1.y) > 1;
|
||||
fire('pointerup', 42, 454, 330);
|
||||
await k.updateComplete;
|
||||
o.kioskVerticalDragPans = panned;
|
||||
o.kioskVerticalDragKeepsFloor = k._space === s1;
|
||||
// zoomed in, a horizontal drag pans (no swipe up there — unchanged rule)
|
||||
k._applyView(2); await k.updateComplete;
|
||||
const v2 = { ...k._view };
|
||||
const s2 = k._space;
|
||||
fire('pointerdown', 43, 600, 300);
|
||||
fire('pointermove', 43, 540, 302);
|
||||
fire('pointermove', 43, 480, 305);
|
||||
const pannedZoomed = Math.abs(k._view.x - v2.x) > 1;
|
||||
fire('pointerup', 43, 480, 305);
|
||||
await k.updateComplete;
|
||||
o.kioskZoomedHorizontalPans = pannedZoomed;
|
||||
o.kioskZoomedDoesNotSwipe = k._space === s2;
|
||||
k.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -39,7 +39,7 @@ const res = await page.evaluate(async () => {
|
||||
const sentF1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
|
||||
const liveF1 = (c._serverCfg?.spaces || []).find((s) => s.id === 'f1');
|
||||
out.sentPlanUrl = sentF1?.plan_url;
|
||||
out.sentAspect = sentF1?.aspect;
|
||||
out.sentPlanAspect = sentF1?.plan_aspect; // the IMAGE's ratio; the canvas is square
|
||||
out.sentTitle = sentF1?.title;
|
||||
out.livePlanUrl = liveF1?.plan_url;
|
||||
out.dialogClosed = c._spaceDialog === null;
|
||||
@@ -52,19 +52,19 @@ const res = await page.evaluate(async () => {
|
||||
const attic = (c.__sent?.spaces || []).find((s) => s.title === 'Attic');
|
||||
out.atticSaved = !!attic;
|
||||
out.atticHasPlan = !!attic && typeof attic.plan_url === 'string' && attic.plan_url.includes('/content/plans/');
|
||||
out.atticAspect = attic?.aspect;
|
||||
out.atticPlanAspect = attic?.plan_aspect;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.44.8 и сверено с кодом
|
||||
checkAll(res, {
|
||||
reloadHappened: true,
|
||||
sentPlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
|
||||
sentAspect: 1.6,
|
||||
sentPlanAspect: 1.6,
|
||||
sentTitle: 'Ground',
|
||||
livePlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
|
||||
dialogClosed: true,
|
||||
atticSaved: true,
|
||||
atticHasPlan: true,
|
||||
atticAspect: 0.8,
|
||||
atticPlanAspect: 0.8,
|
||||
});
|
||||
await finish(browser);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// HP-1552: residual jump on first open in NORMAL (non-kiosk) mode. The stage is
|
||||
// calc(100dvh - _hdrH) and _hdrH is measured from HA's chrome, which loads
|
||||
// AFTER the card's first paint — so the plan painted, then jumped when the
|
||||
// panels landed. The fix hides the plan behind a boot veil (pulsing house)
|
||||
// until the height reads the same twice in a row (or ~600 ms). This smoke
|
||||
// simulates the late HA panel: a spacer above the card grows 0 → 56 px at
|
||||
// t=300 ms. Assertions: the veil is shown, and there is NOT A SINGLE frame
|
||||
// where the plan is visible at a non-final stage height. Kiosk: no veil ever.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
// ---------- phase 1: normal mode, HA panel lands late ----------
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height:0px';
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.append(spacer, c);
|
||||
document.body.appendChild(wrap);
|
||||
|
||||
const frames = [];
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const t0 = performance.now();
|
||||
let grown = false;
|
||||
await new Promise((done) => {
|
||||
const sample = () => {
|
||||
const t = performance.now() - t0;
|
||||
if (!grown && t >= 300) {
|
||||
grown = true; // the "HA panel" arrives: everything above the card grows
|
||||
spacer.style.height = '56px';
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}
|
||||
const stage = sr().querySelector('.stage');
|
||||
if (stage) {
|
||||
const veil = sr().querySelector('.bootveil');
|
||||
const wrapEl = stage.querySelector(':scope > .zoomwrap');
|
||||
const planHidden =
|
||||
!wrapEl ||
|
||||
getComputedStyle(wrapEl).visibility === 'hidden' ||
|
||||
(veil && !veil.classList.contains('off') && getComputedStyle(veil).opacity !== '0');
|
||||
frames.push({
|
||||
t: Math.round(t),
|
||||
planVisible: !planHidden,
|
||||
veil: !!veil,
|
||||
veilBg: veil ? getComputedStyle(veil).backgroundColor : null,
|
||||
house: !!(veil && veil.querySelector('.boothouse path')),
|
||||
stageH: stage.clientHeight,
|
||||
});
|
||||
} else {
|
||||
frames.push(null);
|
||||
}
|
||||
if (t < 1400) requestAnimationFrame(sample);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
|
||||
const seen = frames.filter(Boolean);
|
||||
const finalH = seen[seen.length - 1].stageH;
|
||||
out.sampledFrames = seen.length > 40;
|
||||
out.veilShownWhileSettling = seen.some((f) => f.veil && !f.planVisible && f.house);
|
||||
out.veilIsDark = (() => {
|
||||
const f = seen.find((x) => x.veilBg);
|
||||
const m = f && f.veilBg.match(/(\d+),\s*(\d+),\s*(\d+)/);
|
||||
return !!m && +m[1] + +m[2] + +m[3] < 300; // dark, not white/transparent
|
||||
})();
|
||||
// THE assertion: no frame may show the plan at a stale (pre-panel) height
|
||||
const bad = seen.filter((f) => f.planVisible && Math.abs(f.stageH - finalH) > 2);
|
||||
out.noVisibleFrameAtStaleHeight = bad.length === 0
|
||||
? true
|
||||
: `plan visible at h=${bad[0].stageH} (final ${finalH}) at t=${bad[0].t}ms — the jump is visible`;
|
||||
const shown = seen.filter((f) => f.planVisible);
|
||||
out.planShownAfterSettle = shown.length >= 5 && Math.abs(shown[0].stageH - finalH) <= 2;
|
||||
out.veilGoneInTheEnd = !seen[seen.length - 1].veil;
|
||||
out.heightReactedToPanel = finalH !== seen[0].stageH; // the simulated panel actually moved the stage
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(res)) check(k, v);
|
||||
|
||||
// ---------- phase 2: kiosk never shows the veil ----------
|
||||
const kiosk = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
c.hass = window.__card.hass;
|
||||
c.style.cssText = 'position:fixed;left:0;top:0;width:800px;height:700px;z-index:99';
|
||||
document.body.appendChild(c);
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
let veilEver = false;
|
||||
let planHiddenEver = false;
|
||||
const t0 = performance.now();
|
||||
await new Promise((done) => {
|
||||
const sample = () => {
|
||||
const stage = sr().querySelector('.stage');
|
||||
if (stage) {
|
||||
if (sr().querySelector('.bootveil')) veilEver = true;
|
||||
const wrapEl = stage.querySelector(':scope > .zoomwrap');
|
||||
if (wrapEl && getComputedStyle(wrapEl).visibility === 'hidden') planHiddenEver = true;
|
||||
}
|
||||
if (performance.now() - t0 < 900) requestAnimationFrame(sample);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
out.kioskNoVeil = !veilEver;
|
||||
out.kioskPlanNeverHidden = !planHiddenEver;
|
||||
c.remove();
|
||||
return out;
|
||||
});
|
||||
check('kioskNoVeil', kiosk.kioskNoVeil);
|
||||
check('kioskPlanNeverHidden', kiosk.kioskPlanNeverHidden);
|
||||
|
||||
// ---------- phase 3: prefers-reduced-motion → the house does not pulse ----------
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
const reduced = await page.evaluate(async () => {
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
c.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99';
|
||||
document.body.appendChild(c);
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const t0 = performance.now();
|
||||
let anim = 'missed';
|
||||
await new Promise((done) => {
|
||||
const tick = () => {
|
||||
const house = sr().querySelector('.bootveil .boothouse');
|
||||
if (house) { anim = getComputedStyle(house).animationName; done(); return; }
|
||||
if (performance.now() - t0 < 500) requestAnimationFrame(tick);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
c.remove();
|
||||
return { anim };
|
||||
});
|
||||
check('reducedMotionStaticHouse', reduced.anim, 'none');
|
||||
|
||||
await finish(browser, { ...res, ...kiosk, ...reduced });
|
||||
@@ -0,0 +1,184 @@
|
||||
// AUD-1552-01 + AUD-1552-02: adversarial lifecycle regressions for the
|
||||
// first-open boot veil (HP-1552). Both scenarios FAIL on v1.55.2.
|
||||
//
|
||||
// A. disconnect/reconnect while booting (Lovelace rebuilds its DOM, a view
|
||||
// switch remounts the card). v1.55.2 cleared the boot timer on disconnect
|
||||
// but kept its truthy id, so the reconnect never restarted the watcher and
|
||||
// the plan stayed hidden FOREVER. Now the veil lifecycle restarts from
|
||||
// connectedCallback: the veil must lift no later than the hard cap.
|
||||
// B. layout shifts before, between and after the old poll ticks (150, 300,
|
||||
// 450, 590 ms). v1.55.2 revealed the plan after two equal reads at
|
||||
// 200/400 ms, so a panel landing at 450+ ms jumped on a VISIBLE plan. Now
|
||||
// the veil holds for the full protective window with trailing quiescence:
|
||||
// not a single frame may show the plan at a non-final stage height.
|
||||
// C. a shift AFTER the reveal (slower than the cap) must glide, not snap:
|
||||
// for a short grace the stage height transitions and the plan refits
|
||||
// along with it (no transition at all on v1.55.2 — instant jump).
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
// ---------- A: detach before the first tick, reattach, veil must lift ----------
|
||||
const rec = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.appendChild(c);
|
||||
document.body.appendChild(wrap);
|
||||
await sleep(50); // the first boot tick has not fired yet
|
||||
out.bootingWhenDetached = c._booting === true;
|
||||
c.remove(); // disconnect: timers die here
|
||||
await sleep(30);
|
||||
wrap.appendChild(c); // reconnect — the veil lifecycle must restart
|
||||
const t0 = performance.now();
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
while (c._booting && performance.now() - t0 < 2000) await sleep(50);
|
||||
out.revealMs = Math.round(performance.now() - t0);
|
||||
out.bootSettledAfterReconnect = c._booting === false;
|
||||
out.settledWithinCap = performance.now() - t0 < 1500; // hard cap 1200 + slack
|
||||
await sleep(300); // let the fade finish
|
||||
const stage = sr().querySelector('.stage');
|
||||
const wrapEl = stage && stage.querySelector(':scope > .zoomwrap');
|
||||
out.planVisibleAfterReconnect = !!wrapEl && getComputedStyle(wrapEl).visibility === 'visible';
|
||||
out.noHpbootClass = !!stage && !stage.classList.contains('hpboot');
|
||||
out.veilGone = !sr().querySelector('.bootveil');
|
||||
|
||||
// A2: a second detach DURING the fade must not leave a zombie veil either
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const c2 = document.createElement('houseplan-card');
|
||||
c2.setConfig({ type: 'custom:houseplan-card' });
|
||||
c2.hass = window.__card.hass;
|
||||
wrap.appendChild(c2);
|
||||
const t1 = performance.now();
|
||||
while (c2._booting && performance.now() - t1 < 2000) await sleep(50);
|
||||
// _bootFading is true for ~220 ms after the reveal — detach right inside it
|
||||
c2.remove();
|
||||
await sleep(30);
|
||||
wrap.appendChild(c2);
|
||||
await sleep(400);
|
||||
const sr2 = c2.shadowRoot || c2.renderRoot;
|
||||
out.fadeVeilGoneAfterReattach = !sr2.querySelector('.bootveil') && c2._bootFading === false;
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(rec)) check(k, v, k === 'revealMs' ? rec.revealMs : true);
|
||||
|
||||
// ---------- B: shifts at 150/300/450/590 ms — never a visible stale frame ----------
|
||||
const shifts = [150, 300, 450, 590];
|
||||
const outB = {};
|
||||
for (const shiftMs of shifts) {
|
||||
const res = await page.evaluate(async (shiftAt) => {
|
||||
const out = {};
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height:0px';
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.append(spacer, c);
|
||||
document.body.appendChild(wrap);
|
||||
const frames = [];
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const t0 = performance.now();
|
||||
let grown = false;
|
||||
await new Promise((done) => {
|
||||
const sample = () => {
|
||||
const t = performance.now() - t0;
|
||||
if (!grown && t >= shiftAt) {
|
||||
grown = true; // the "HA panel" arrives
|
||||
spacer.style.height = '56px';
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}
|
||||
const stage = sr().querySelector('.stage');
|
||||
if (stage) {
|
||||
const veil = sr().querySelector('.bootveil');
|
||||
const wrapEl = stage.querySelector(':scope > .zoomwrap');
|
||||
const planHidden =
|
||||
!wrapEl ||
|
||||
getComputedStyle(wrapEl).visibility === 'hidden' ||
|
||||
(veil && !veil.classList.contains('off') && getComputedStyle(veil).opacity !== '0');
|
||||
frames.push({ t: Math.round(t), planVisible: !planHidden, stageH: stage.clientHeight });
|
||||
}
|
||||
if (t < 1900) requestAnimationFrame(sample);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
const seen = frames;
|
||||
const finalH = seen[seen.length - 1].stageH;
|
||||
// THE assertion: no frame may show the plan at a stale (pre-panel) height
|
||||
const bad = seen.filter((f) => f.planVisible && Math.abs(f.stageH - finalH) > 2);
|
||||
out.noStaleFrame = bad.length === 0
|
||||
? true
|
||||
: `plan visible at h=${bad[0].stageH} (final ${finalH}) at t=${bad[0].t}ms`;
|
||||
const shown = seen.filter((f) => f.planVisible);
|
||||
out.planShown = shown.length >= 5 && Math.abs(shown[0].stageH - finalH) <= 2;
|
||||
out.heightReacted = finalH !== seen[0].stageH;
|
||||
wrap.remove();
|
||||
return out;
|
||||
}, shiftMs);
|
||||
outB[`shift${shiftMs}_noStaleFrame`] = res.noStaleFrame;
|
||||
outB[`shift${shiftMs}_planShown`] = res.planShown;
|
||||
outB[`shift${shiftMs}_heightReacted`] = res.heightReacted;
|
||||
check(`shift${shiftMs}_noStaleFrame`, res.noStaleFrame);
|
||||
check(`shift${shiftMs}_planShown`, res.planShown);
|
||||
check(`shift${shiftMs}_heightReacted`, res.heightReacted);
|
||||
}
|
||||
|
||||
// ---------- C: shift AFTER the reveal glides instead of snapping ----------
|
||||
const late = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height:0px';
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.append(spacer, c);
|
||||
document.body.appendChild(wrap);
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const t0 = performance.now();
|
||||
while (c._booting && performance.now() - t0 < 2500) await sleep(25);
|
||||
out.revealed = c._booting === false;
|
||||
await sleep(120); // clearly after the reveal, still inside the soft grace
|
||||
const stage = sr().querySelector('.stage');
|
||||
const h0 = stage.clientHeight;
|
||||
spacer.style.height = '56px'; // VERY late panel — slower than the cap
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
const heights = [];
|
||||
await new Promise((done) => {
|
||||
const t1 = performance.now();
|
||||
const sample = () => {
|
||||
heights.push(stage.clientHeight);
|
||||
if (performance.now() - t1 < 700) requestAnimationFrame(sample);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
const hEnd = heights[heights.length - 1];
|
||||
out.heightMoved = Math.abs(hEnd - h0) > 30;
|
||||
// the glide: intermediate frames strictly between the endpoints
|
||||
const mid = heights.filter((h) => Math.abs(h - h0) > 4 && Math.abs(h - hEnd) > 4);
|
||||
out.lateShiftGlides = mid.length >= 3
|
||||
? true
|
||||
: `only ${mid.length} intermediate frame(s): ${h0} -> ${hEnd} snapped`;
|
||||
out.settledAtFinal = Math.abs(hEnd - (h0 - 56)) <= 2;
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
check('late_revealed', late.revealed);
|
||||
check('late_heightMoved', late.heightMoved);
|
||||
check('late_lateShiftGlides', late.lateShiftGlides);
|
||||
check('late_settledAtFinal', late.settledAtFinal);
|
||||
|
||||
await finish(browser, { ...rec, ...outB, ...late });
|
||||
@@ -0,0 +1,59 @@
|
||||
// HP-1454-07: статическая карточка строит модель другой функцией, и room.settings
|
||||
// в неё не переносились — переопределение заливки на уровне комнаты она
|
||||
// игнорировала и красила комнату, которую полная карточка оставляет прозрачной.
|
||||
// Плюс HP-1454-08: layout-события должны доходить до статической карточки без
|
||||
// перезагрузки страницы.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const main = window.__card;
|
||||
|
||||
// заливка по свету на пространстве, у первой комнаты — переопределение "none"
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1.settings = { ...(f1.settings || {}), show_borders: true, show_names: true, fill_mode: 'light' };
|
||||
f1.rooms[0].settings = { fill_mode: 'none' };
|
||||
const hass = { ...main.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
|
||||
return { ok: true };
|
||||
} };
|
||||
|
||||
main._serverCfg = cfg;
|
||||
main._cfgEpoch++;
|
||||
main.requestUpdate(); await main.updateComplete;
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
|
||||
const overridden = (root) => {
|
||||
const rooms = [...root.querySelectorAll('.room')];
|
||||
return rooms.length ? ((rooms[0].getAttribute('style') || '').match(/--room-fill:([^;]+)/) || [])[1] || null : 'missing';
|
||||
};
|
||||
|
||||
out.fullCardRoom0 = overridden(main.shadowRoot || main.renderRoot);
|
||||
out.staticCardRoom0 = overridden(card.renderRoot);
|
||||
out.parity = out.fullCardRoom0 === out.staticCardRoom0;
|
||||
out.overrideRespected = out.staticCardRoom0 === 'transparent';
|
||||
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
fullCardRoom0: 'transparent',
|
||||
staticCardRoom0: 'transparent',
|
||||
parity: true,
|
||||
overrideRespected: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,210 @@
|
||||
// The v1.55.0 resize audit (HP-1550-01/-03/-04), numeric regressions:
|
||||
// 01 — a debounced config write pending from a PREVIOUS edit must never carry
|
||||
// the live resize preview to the server; Esc leaves server === snapshot;
|
||||
// a normal pointerup produces exactly one write;
|
||||
// 03 — pointercancel aborts the drag (no commit, no undo step, no write),
|
||||
// for wall handles and corner handles alike; lostpointercapture after
|
||||
// a cancel must not double-fire;
|
||||
// 04 — a door at the exact midpoint of a wall must not shadow the resize
|
||||
// handle: the handle wins the hit test, the wall drags WITH the door,
|
||||
// and no opening-edit drag starts in the resize tool.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
|
||||
const restore = () => page.evaluate((s) => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = JSON.parse(s);
|
||||
c._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszUndo = [];
|
||||
if ('_rszPreview' in c) c._rszPreview = null;
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, snap);
|
||||
const enter = (tool) => page.evaluate((t) => {
|
||||
const c = window.__card;
|
||||
if (!c._markup) c._setMode('plan');
|
||||
c._tool = t; c._rszSel = null; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, tool);
|
||||
const settle = () => page.evaluate(() => new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res))));
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const c = window.__card;
|
||||
const stage = c.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const svg = stage.querySelector('svg');
|
||||
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
// geometry the SERVER would get (the mutable config _writeConfig reads)
|
||||
const roomSrv = (id) => page.evaluate((id) => {
|
||||
const r = window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms.find((x) => x.id === id);
|
||||
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
|
||||
}, id);
|
||||
// geometry AS RENDERED (includes the live preview overlay when a drag is on)
|
||||
const roomLive = (id) => page.evaluate((id) => {
|
||||
const r = window.__card._curSpaceCfg.rooms.find((x) => x.id === id);
|
||||
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
|
||||
}, id);
|
||||
|
||||
// spy on every config write with a deep copy taken AT CALL TIME — exactly what
|
||||
// the backend would persist (a later fix of the mutable object must not hide it)
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
window.__writes = [];
|
||||
const orig = c.hass.callWS.bind(c.hass);
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/set') window.__writes.push(JSON.parse(JSON.stringify(m.config)));
|
||||
return orig(m);
|
||||
} };
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
const writes = () => page.evaluate(() => window.__writes.length);
|
||||
const writeWallAt = (i, x) => page.evaluate(([i, x]) => {
|
||||
const w = window.__writes[i];
|
||||
const r1 = w.spaces.find((s) => s.id === 'f1').rooms.find((r) => r.id === 'r1');
|
||||
return r1.poly.some((p) => Math.abs(p[0] - x) < 0.011);
|
||||
}, [i, x]);
|
||||
|
||||
// ================= HP-1550-01: pending write vs live preview =================
|
||||
await enter('resize');
|
||||
await settle();
|
||||
// precompute every screen point so drag 2 starts well inside the 500 ms window
|
||||
const [h550x, h550y] = await screenPt(550, 360);
|
||||
const [h600x] = await screenPt(600, 360);
|
||||
const [h650x] = await screenPt(650, 360);
|
||||
const [h700x] = await screenPt(700, 360);
|
||||
// drag 1 commits r1's right wall 0.55 → 0.60: a write enters the debounce queue
|
||||
await page.mouse.move(h550x, h550y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(h600x, h550y, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
// drag 2 starts IMMEDIATELY (pending save still queued): preview 0.60 → 0.70, HOLD
|
||||
await page.mouse.move(h600x, h550y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(h700x, h550y, { steps: 3 });
|
||||
await settle();
|
||||
check('01_committed_wall', Math.abs((await roomSrv('r1'))[1][0] - 0.60) < 0.011);
|
||||
check('01_preview_live', Math.abs((await roomLive('r1'))[1][0] - 0.70) < 0.011);
|
||||
// … the queued write fires while the handle is still held (>500 ms)
|
||||
await page.waitForTimeout(800);
|
||||
check('01_pending_write_fired', (await writes()) >= 1);
|
||||
// Esc abandons the drag
|
||||
await page.keyboard.press('Escape');
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(800); // any stray debounced write would have fired
|
||||
const nw = await writes();
|
||||
for (let i = 0; i < nw; i++) {
|
||||
check(`01_write_${i}_no_preview_coords`, await writeWallAt(i, 0.70), false);
|
||||
check(`01_write_${i}_has_committed`, await writeWallAt(i, 0.60), true);
|
||||
}
|
||||
check('01_server_geom_is_snapshot', Math.abs((await roomSrv('r1'))[1][0] - 0.60) < 1e-6);
|
||||
check('01_local_geom_is_snapshot', Math.abs((await roomLive('r1'))[1][0] - 0.60) < 1e-6);
|
||||
// a normal pointerup still produces EXACTLY ONE write
|
||||
{
|
||||
const before = await writes();
|
||||
await page.mouse.move(h600x, h550y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(h650x, h550y, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(800);
|
||||
check('01_single_write_per_commit', (await writes()) - before, 1);
|
||||
check('01_commit_reached_server', Math.abs((await roomSrv('r1'))[1][0] - 0.65) < 0.011);
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ================= HP-1550-03: pointercancel = abort, not commit =============
|
||||
await enter('resize');
|
||||
await settle();
|
||||
{
|
||||
const w0 = await writes();
|
||||
const [ax, ay] = await screenPt(550, 360);
|
||||
const [bx] = await screenPt(650, 360);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, ay, { steps: 3 });
|
||||
await settle();
|
||||
check('03_preview_moved', Math.abs((await roomLive('r1'))[1][0] - 0.65) < 0.011);
|
||||
// the system interrupts the stream (app switch, palm rejection)
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const pid = c._rszDrag.pid;
|
||||
const el = c.renderRoot.querySelector('.rszhandle');
|
||||
el.dispatchEvent(new PointerEvent('pointercancel', { pointerId: pid, bubbles: true }));
|
||||
// the lostpointercapture that follows a cancel must not double-fire
|
||||
el.dispatchEvent(new PointerEvent('lostpointercapture', { pointerId: pid, bubbles: true }));
|
||||
});
|
||||
await settle();
|
||||
check('03_snapshot_back_live', Math.abs((await roomLive('r1'))[1][0] - 0.55) < 1e-6);
|
||||
check('03_server_untouched', Math.abs((await roomSrv('r1'))[1][0] - 0.55) < 1e-6);
|
||||
check('03_undo_empty', await page.evaluate(() => window.__card._rszUndo.length), 0);
|
||||
check('03_drag_cleared', await page.evaluate(() => window.__card._rszDrag === null));
|
||||
await page.waitForTimeout(800);
|
||||
check('03_no_pending_write', (await writes()) - w0, 0);
|
||||
await page.mouse.up();
|
||||
}
|
||||
await restore();
|
||||
// the corner (scale) handle takes the same abort path
|
||||
await enter('resize');
|
||||
await page.evaluate(() => { const c = window.__card; c._rszSel = 'r4'; c.requestUpdate(); return c.updateComplete && true; });
|
||||
await settle();
|
||||
{
|
||||
const w0 = await writes();
|
||||
const [ax, ay] = await screenPt(550, 860);
|
||||
const [bx, by] = await screenPt(448, 804); // ≈ k=0.8 about (40,580)
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
check('03_corner_preview', Math.abs((await roomLive('r4'))[2][0] - 0.55) > 0.02);
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const pid = c._rszDrag.pid;
|
||||
const el = c.renderRoot.querySelector('.rszcorner') || c.renderRoot.querySelector('.rszhandle');
|
||||
el.dispatchEvent(new PointerEvent('pointercancel', { pointerId: pid, bubbles: true }));
|
||||
});
|
||||
await settle();
|
||||
check('03_corner_snapshot_back', Math.abs((await roomLive('r4'))[2][0] - 0.55) < 1e-6);
|
||||
check('03_corner_undo_empty', await page.evaluate(() => window.__card._rszUndo.length), 0);
|
||||
await page.waitForTimeout(800);
|
||||
check('03_corner_no_write', (await writes()) - w0, 0);
|
||||
await page.mouse.up();
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ============ HP-1550-04: a door mid-wall must not shadow the handle =========
|
||||
await enter('resize');
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
// exactly at the midpoint of r1's right wall (y 0.14..0.58 → 0.36)
|
||||
sp.openings = [{ id: 'opm', type: 'door', x: 0.55, y: 0.36, angle: 90, length: 0.08 }];
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
await settle();
|
||||
{
|
||||
const [hx, hy] = await screenPt(550, 360);
|
||||
const [tx] = await screenPt(650, 360);
|
||||
// the REAL hit test: the topmost interactive element must be the handle
|
||||
const top = await page.evaluate(([x, y]) => {
|
||||
const el = window.__card.shadowRoot.elementFromPoint(x, y);
|
||||
return el ? el.getAttribute('class') || '' : '';
|
||||
}, [hx, hy]);
|
||||
check('04_handle_wins_hit_test', String(top).includes('rszhandle'));
|
||||
await page.mouse.move(hx, hy);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(tx, hy, { steps: 4 });
|
||||
await settle();
|
||||
check('04_no_opening_drag', await page.evaluate(() => !window.__card._opDrag));
|
||||
check('04_wall_drag_started', await page.evaluate(() => !!window.__card._rszDrag));
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
check('04_wall_moved', Math.abs((await roomSrv('r1'))[1][0] - 0.65) < 0.011);
|
||||
const op = await page.evaluate(() => window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0]);
|
||||
check('04_door_travelled_with_wall', Math.abs(op.x - 0.65) < 0.011);
|
||||
}
|
||||
await restore();
|
||||
|
||||
await finish(browser, { done: true });
|
||||
@@ -8,9 +8,20 @@ const res = await page.evaluate(async () => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { entity_id: 'light.ceiling', state: 'on', attributes: { friendly_name: 'Ceiling light', rgb_color: [255, 0, 128] } } } };
|
||||
await c.updateComplete;
|
||||
const rgbDev = sr().querySelector('.dev.rgb');
|
||||
out.rgbClass = !!rgbDev;
|
||||
out.rgbVar = rgbDev?.getAttribute('style')?.includes('--light-color:rgb(255, 0, 128)');
|
||||
// v1.52.0 (правило владельца): RGB больше НЕ красит значок — цвет лампы
|
||||
// живёт только в glow-пятне и в фолбэке цвета пульсации
|
||||
out.rgbClassGone = !sr().querySelector('.dev.rgb');
|
||||
out.litLampIsYellow = [...sr().querySelectorAll('.dev.on')].length > 0;
|
||||
// фолбэк пульсации сохраняет цвет свечения: маркер icon_ripple на лампе
|
||||
const lampDev = c._devices.find((x) => x.entities.includes('light.ceiling'));
|
||||
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== lampDev.id);
|
||||
c._serverCfg.markers.push({ id: lampDev.id, binding: 'device:' + lampDev.bindingRef, display: 'icon_ripple' });
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const rippleDev = [...sr().querySelectorAll('.dev')].find((e) => (e.getAttribute('style') || '').includes('--ripple-color'));
|
||||
out.rippleKeepsLightColor = !!rippleDev && rippleDev.getAttribute('style').includes('rgb(255, 0, 128)');
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lampDev.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
// тревога: датчик протечки on
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'on', attributes: { friendly_name: 'Leak', device_class: 'moisture' } } } };
|
||||
|
||||
@@ -36,8 +36,14 @@ const res = await page.evaluate(async () => {
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const planLbl = sr().querySelector('.roomlabel');
|
||||
out.handlesInPlan = planLbl?.querySelectorAll('.rlhandle').length === 4;
|
||||
// метрики скрыты в редакторе плана (карточка = имя, чтобы не мешать разметке)
|
||||
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length === 0;
|
||||
// метрики видны и в редакторе плана (решение владельца, 2026-07-29)
|
||||
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length > 0;
|
||||
// кнопка настроек — отдельно от подписи, в центре комнаты, с размером от иконки
|
||||
out.gearDetached = (() => {
|
||||
const g = sr().querySelector('.rlgearbtn');
|
||||
if (!g || g.closest('.roomlabel')) return false;
|
||||
return getComputedStyle(g).height !== 'auto';
|
||||
})();
|
||||
// масштаб: сымитируем resize через прямой вызов
|
||||
const room = c._spaceModel().rooms.find((r) => r.name);
|
||||
c._rlResize = { id: 'rl_' + room.id, space: spId, k0: 1, cx: 100, cy: 100, d0: 50 };
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// Room resize tool (docs/RESIZE.md): wall drag with shared walls (T-junction),
|
||||
// live badges, stops (neighbour min size, opening anchor), the corner scale
|
||||
// frame, Esc-cancel and one-step undo. Vertex positions are checked NUMERICALLY.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
|
||||
const restore = () => page.evaluate((s) => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = JSON.parse(s);
|
||||
c._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszUndo = []; c._rszPreview = null;
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, snap);
|
||||
|
||||
const enter = (tool) => page.evaluate((t) => {
|
||||
const c = window.__card;
|
||||
if (!c._markup) c._setMode('plan');
|
||||
c._tool = t; c._rszSel = null; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, tool);
|
||||
|
||||
const handleCount = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.rszhandle').length);
|
||||
const roomPolyN = (id) => page.evaluate((id) => {
|
||||
const r = window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms.find((x) => x.id === id);
|
||||
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
|
||||
}, id);
|
||||
// mid-drag geometry AS RENDERED: HP-1550-01 moved the live preview out of
|
||||
// _serverCfg into the _rszPreview overlay (_curSpaceCfg serves it to renders)
|
||||
const roomPolyLive = (id) => page.evaluate((id) => {
|
||||
const r = window.__card._curSpaceCfg.rooms.find((x) => x.id === id);
|
||||
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
|
||||
}, id);
|
||||
const openingOf = (id) => page.evaluate((id) =>
|
||||
(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings || []).find((o) => o.id === id) || null, id);
|
||||
// screen position of a render-unit point (the svg viewBox fills the stage exactly)
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const c = window.__card;
|
||||
const stage = c.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const svg = stage.querySelector('svg');
|
||||
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
const settle = () => page.evaluate(() => new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res))));
|
||||
const labels = () => page.evaluate(() =>
|
||||
[...window.__card.renderRoot.querySelectorAll('.measurelabel')].map((el) => el.textContent.trim()));
|
||||
const polyClose = (name, got, want, tol = 1e-4) => {
|
||||
check(name + '.len', got?.length, want.length);
|
||||
if (got?.length !== want.length) return;
|
||||
for (let i = 0; i < want.length; i++) {
|
||||
check(`${name}[${i}]`, Math.hypot(got[i][0] - want[i][0], got[i][1] - want[i][1]) <= tol, true);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- no handles outside the resize tool ----------
|
||||
await enter('draw');
|
||||
check('draw_tool_no_handles', await handleCount(), 0);
|
||||
await enter('opening');
|
||||
check('opening_tool_no_handles', await handleCount(), 0);
|
||||
|
||||
// ---------- handles appear in the resize tool ----------
|
||||
await enter('resize');
|
||||
await settle();
|
||||
check('resize_handles_16', await handleCount(), 16); // 4 rooms × 4 walls
|
||||
|
||||
// ---------- wall drag: T-stack r1|r2+r3, opening rides along, live badges ----------
|
||||
// opening op2 sits ON the shared wall x=0.55 → it must travel with it
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [{ id: 'op2', type: 'door', x: 0.55, y: 0.30, angle: 90, length: 0.08 }];
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
});
|
||||
await settle();
|
||||
const [hx, hy] = await screenPt(550, 360); // r1 right-wall handle (mid y of 140..580)
|
||||
const [tx] = await screenPt(650, 360); // +100 render units
|
||||
await page.mouse.move(hx, hy);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move((hx + tx) / 2, hy, { steps: 4 });
|
||||
await settle();
|
||||
const mid = await labels();
|
||||
check('drag_badges_visible', mid.length >= 6); // 3 wall lengths + 3 areas (r1, r2, r3)
|
||||
check('drag_badges_have_area', mid.filter((t) => t.includes('m²')).length >= 3);
|
||||
check('drag_badges_have_len', mid.some((t) => /\d\.\d\d m$/.test(t)));
|
||||
const midR1 = await roomPolyLive('r1');
|
||||
check('preview_moves_r1', Math.abs(midR1[1][0] - 0.6) < 0.011); // halfway ≈ 0.60 (snapped)
|
||||
// HP-1550-01: the preview must NOT leak into the config a queued write reads
|
||||
const midSrv = await roomPolyN('r1');
|
||||
check('preview_not_in_servercfg', Math.abs(midSrv[1][0] - 0.55) < 1e-9);
|
||||
await page.mouse.move(tx, hy, { steps: 4 });
|
||||
await settle();
|
||||
const end = await labels();
|
||||
check('drag_badges_change', JSON.stringify(mid) !== JSON.stringify(end));
|
||||
check('area_updates_live', mid.find((t) => t.includes('m²')) !== end.find((t) => t.includes('m²')));
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
// committed geometry: r1 grew, r2 translated, r3 became L-shaped with 6 vertices
|
||||
polyClose('r1_after', await roomPolyN('r1'), [[0.04, 0.14], [0.65, 0.14], [0.65, 0.58], [0.04, 0.58]]);
|
||||
polyClose('r2_after', await roomPolyN('r2'), [[0.65, 0.14], [0.96, 0.14], [0.96, 0.46], [0.65, 0.46]]);
|
||||
polyClose('r3_after', await roomPolyN('r3'),
|
||||
[[0.65, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86], [0.55, 0.58], [0.65, 0.58]]);
|
||||
const op2 = await openingOf('op2');
|
||||
check('opening_travels', Math.abs(op2.x - 0.65) < 1e-4 && Math.abs(op2.y - 0.30) < 1e-6);
|
||||
|
||||
// ---------- undo: one release = one step back ----------
|
||||
await page.keyboard.press('Control+z');
|
||||
await settle();
|
||||
polyClose('undo_r1', await roomPolyN('r1'), [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]);
|
||||
polyClose('undo_r2', await roomPolyN('r2'), [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]]);
|
||||
polyClose('undo_r3', await roomPolyN('r3'), [[0.55, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86]]);
|
||||
const op2b = await openingOf('op2');
|
||||
check('undo_opening_back', Math.abs(op2b.x - 0.55) < 1e-9);
|
||||
await restore();
|
||||
|
||||
// ---------- stop: the shrinking neighbour keeps ~30 cm ----------
|
||||
await enter('resize');
|
||||
await settle();
|
||||
{
|
||||
const [ax, ay] = await screenPt(550, 360);
|
||||
const [bx] = await screenPt(1050, 360); // far past r2/r3 (right walls at 0.96)
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, ay, { steps: 6 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const r1 = await roomPolyN('r1');
|
||||
const r2 = await roomPolyN('r2');
|
||||
const width2 = (r2[1][0] - r2[0][0]) * 1000;
|
||||
check('neighbour_min_stop', Math.abs(r1[1][0] - 0.93333) < 0.005); // clamped, NOT 1.05
|
||||
check('neighbour_min_width', width2 >= 25 - 1e-6 && width2 <= 25 + 1000 / 240 + 1e-6);
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ---------- stop: an opening anchors the shortening wall ----------
|
||||
await enter('resize');
|
||||
await settle();
|
||||
{
|
||||
// a door on r1's LEFT wall (x=0.04), spans y 0.16..0.24. Dragging r1's TOP
|
||||
// wall down shortens that wall from above — it must stop at the door edge.
|
||||
// (No collinear neighbour wall can adopt this door — a real «упор».)
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [{ id: 'op1', type: 'door', x: 0.04, y: 0.20, angle: 90, length: 0.08 }];
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
});
|
||||
await settle();
|
||||
const [ax, ay] = await screenPt(295, 140); // r1 top-wall handle
|
||||
const [, by] = await screenPt(295, 300); // try to push far past the door top (y=160)
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(ax, by, { steps: 5 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const r1 = await roomPolyN('r1');
|
||||
const topY = r1[0][1] * 1000;
|
||||
check('opening_stop', topY <= 160 + 1e-6); // never below the door top
|
||||
check('opening_stop_close', topY >= 160 - 1000 / 240 - 1e-6); // and clamped right at it
|
||||
const op1 = await openingOf('op1');
|
||||
check('anchored_opening_static', Math.abs(op1.y - 0.2) < 1e-9 && Math.abs(op1.x - 0.04) < 1e-9);
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ---------- the scale frame: proportional, neighbours stay, stops work ----------
|
||||
await enter('resize');
|
||||
await page.evaluate(() => { const c = window.__card; c._rszSel = 'r4'; c.requestUpdate(); return c.updateComplete && true; });
|
||||
await settle();
|
||||
check('frame_corners', await page.evaluate(() => window.__card.renderRoot.querySelectorAll('.rszcorner').length), 4);
|
||||
check('frame_present', await page.evaluate(() => !!window.__card.renderRoot.querySelector('.rszframe')));
|
||||
{
|
||||
// shrink about the top-left corner (40,580): drag br (550,860) toward it
|
||||
const [ax, ay] = await screenPt(550, 860);
|
||||
const [bx, by] = await screenPt(448, 804); // ≈ k=0.8
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, by, { steps: 5 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const r4 = await roomPolyN('r4');
|
||||
const base = [[0.04, 0.58], [0.55, 0.58], [0.55, 0.86], [0.04, 0.86]];
|
||||
// ALL vertices scale by the SAME factor about the fixed corner (0.04, 0.58)
|
||||
const ks = base.map((b, i) => {
|
||||
const dx = b[0] - 0.04, dy = b[1] - 0.58;
|
||||
if (Math.abs(dx) > 1e-9) return (r4[i][0] - 0.04) / dx;
|
||||
if (Math.abs(dy) > 1e-9) return (r4[i][1] - 0.58) / dy;
|
||||
return null;
|
||||
}).filter((k) => k != null);
|
||||
const kAvg = ks.reduce((a, b) => a + b, 0) / ks.length;
|
||||
check('scale_shrinks', kAvg > 0.7 && kAvg < 0.9);
|
||||
check('scale_proportional', ks.every((k) => Math.abs(k - kAvg) < 1e-3));
|
||||
const ky = base.map((b, i) => (Math.abs(b[1] - 0.58) > 1e-9 ? (r4[i][1] - 0.58) / (b[1] - 0.58) : null)).filter((k) => k != null);
|
||||
check('scale_uniform_xy', ky.every((k) => Math.abs(k - kAvg) < 1e-3));
|
||||
// neighbours did NOT move (scale never drags them — docs/RESIZE.md exception)
|
||||
polyClose('scale_r1_static', await roomPolyN('r1'), [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]);
|
||||
polyClose('scale_r3_static', await roomPolyN('r3'), [[0.55, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86]]);
|
||||
}
|
||||
await restore();
|
||||
await enter('resize');
|
||||
await page.evaluate(() => { const c = window.__card; c._rszSel = 'r4'; c.requestUpdate(); return c.updateComplete && true; });
|
||||
await settle();
|
||||
{
|
||||
// growing about the top-left corner immediately runs into r3 → the scale stops at ≈1
|
||||
const [ax, ay] = await screenPt(550, 860);
|
||||
const [bx, by] = await screenPt(805, 1000); // ≈ k=1.5
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, by, { steps: 5 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const r4 = await roomPolyN('r4');
|
||||
check('scale_neighbour_stop', Math.abs(r4[1][0] - 0.55) < 0.005 && Math.abs(r4[2][1] - 0.86) < 0.005);
|
||||
polyClose('scale_stop_r3_static', await roomPolyN('r3'), [[0.55, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86]]);
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ---------- Esc cancels the current drag ----------
|
||||
await enter('resize');
|
||||
await settle();
|
||||
{
|
||||
const [ax, ay] = await screenPt(550, 360);
|
||||
const [bx] = await screenPt(700, 360);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, ay, { steps: 4 });
|
||||
await settle();
|
||||
const midPoly = await roomPolyLive('r1');
|
||||
check('esc_preview_moved', Math.abs(midPoly[1][0] - 0.70) < 0.011);
|
||||
await page.keyboard.press('Escape');
|
||||
await settle();
|
||||
polyClose('esc_restores_r1', await roomPolyN('r1'), [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]);
|
||||
polyClose('esc_restores_r2', await roomPolyN('r2'), [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]]);
|
||||
check('esc_no_badges', (await labels()).length, 0);
|
||||
await page.mouse.up();
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ---------- leaving the tool removes every handle ----------
|
||||
await enter('merge');
|
||||
check('merge_tool_no_handles', await handleCount(), 0);
|
||||
await page.evaluate(() => { window.__card._setMode('devices'); });
|
||||
await settle();
|
||||
check('devices_mode_no_handles', await handleCount(), 0);
|
||||
|
||||
await finish(browser, { done: true });
|
||||
@@ -0,0 +1,93 @@
|
||||
// «Уже загруженные»: план, который не удаляется за ненадобностью, обязан быть
|
||||
// находимым. Иначе обещание «отцепил — файл остался» неполноценно: вернуть его
|
||||
// из карточки было нельзя, старый URL нигде не хранится (HP-1466-02).
|
||||
// Заодно это единственный способ удалить план — явным действием.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 1000 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const base = c.hass.callWS;
|
||||
|
||||
let serverPlans = [
|
||||
{ name: 'f1.aaa.png', url: '/api/houseplan/content/plans/_/f1.aaa.png', size: 121335, modified: 2, used_by: [] },
|
||||
{ name: 'f2.bbb.png', url: '/api/houseplan/content/plans/_/f2.bbb.png', size: 26931, modified: 1, used_by: ['2 этаж'] },
|
||||
];
|
||||
const deleted = [];
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plans/list') return { plans: serverPlans };
|
||||
if (m.type === 'houseplan/plans/delete') {
|
||||
const p = serverPlans.find((x) => x.name === m.name);
|
||||
if (p?.used_by.length) { const e = new Error('in_use'); e.code = 'in_use'; throw e; }
|
||||
deleted.push(m.name);
|
||||
serverPlans = serverPlans.filter((x) => x.name !== m.name);
|
||||
return { ok: true, removed: true };
|
||||
}
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
const urls = {}; for (const p of m.paths) urls[p] = p + '?authSig=X'; return { urls };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
window.confirm = () => true;
|
||||
|
||||
// пространство без плана — как после отцепления
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
|
||||
await c.updateComplete;
|
||||
out.saveBlockedWithoutPlan = !!sr().querySelector('.dialog .btn.on[disabled]');
|
||||
|
||||
// открываем список сохранённых
|
||||
await c._toggleServerPlans();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await c.updateComplete;
|
||||
const rows = [...sr().querySelectorAll('.savedplan')];
|
||||
out.listed = rows.length;
|
||||
out.showsUsage = (rows[1]?.textContent || '').includes('2 этаж');
|
||||
out.deleteDisabledForUsed = !!rows[1]?.querySelector('.btn.danger[disabled]');
|
||||
out.deleteEnabledForFree = !rows[0]?.querySelector('.btn.danger[disabled]');
|
||||
out.thumbnailSigned = (rows[0]?.querySelector('img')?.getAttribute('src') || '').includes('authSig=');
|
||||
|
||||
// выбираем свободный план — он подставляется в диалог
|
||||
c._useServerPlan(serverPlans[0].url);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await c.updateComplete;
|
||||
out.picked = c._spaceDialog.planUrl === '/api/houseplan/content/plans/_/f1.aaa.png';
|
||||
out.listClosed = !c._spaceDialog.pickSaved;
|
||||
out.saveEnabledAfterPick = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||
|
||||
// выбранный в этом же диалоге удалить нельзя: сохранение записало бы ссылку
|
||||
// на несуществующий файл (HP-1470-02)
|
||||
await c._toggleServerPlans();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await c.updateComplete;
|
||||
const rows2 = [...sr().querySelectorAll('.savedplan')];
|
||||
const picked = rows2.find((r) => r.textContent.includes('f1.aaa.png'));
|
||||
out.deleteDisabledForPicked = !!picked?.querySelector('.btn.danger[disabled]');
|
||||
c._spaceDialog = { ...c._spaceDialog, planUrl: null };
|
||||
await c.updateComplete;
|
||||
await c._deleteServerPlan('f2.bbb.png').catch(() => {});
|
||||
out.usedNotDeleted = !deleted.includes('f2.bbb.png');
|
||||
await c._deleteServerPlan('f1.aaa.png');
|
||||
await c.updateComplete;
|
||||
out.freeDeleted = deleted.includes('f1.aaa.png');
|
||||
out.rowGone = !(c._spaceDialog.saved || []).some((p) => p.name === 'f1.aaa.png');
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.47.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
saveBlockedWithoutPlan: true,
|
||||
listed: 2,
|
||||
showsUsage: true,
|
||||
deleteDisabledForUsed: true,
|
||||
deleteEnabledForFree: true,
|
||||
thumbnailSigned: true,
|
||||
picked: true,
|
||||
listClosed: true,
|
||||
saveEnabledAfterPick: true,
|
||||
deleteDisabledForPicked: true,
|
||||
usedNotDeleted: true,
|
||||
freeDeleted: true,
|
||||
rowGone: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,90 @@
|
||||
// HP-1513-01: один и тот же маркер (size 3, angle 37) обязан выглядеть
|
||||
// одинаково на полной и статичной карточках — статичная игнорировала и
|
||||
// множитель размера, и поворот.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.markers = (cfg.markers || []).filter((m) => m.id !== d.id);
|
||||
cfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, size: 3, angle: 37 });
|
||||
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const pick = (root) => [...root.querySelectorAll('.dev')]
|
||||
.find((e) => (e.getAttribute('style') || '').includes('--dev-scale:3'));
|
||||
const full = pick(c.shadowRoot || c.renderRoot);
|
||||
const fW = full?.getBoundingClientRect().width || 0;
|
||||
const fT = full ? getComputedStyle(full.querySelector('ha-icon')).transform : 'none';
|
||||
|
||||
window.__hpInvalidate?.();
|
||||
const hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 83 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: c._layout, rev: 83 };
|
||||
return { ok: true };
|
||||
}, connection: { subscribeEvents: async (cb) => { window.__hpInvalidate = cb; return () => {}; } } };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!pick(card.renderRoot || { querySelectorAll: () => [] }) && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
const st = pick(card.renderRoot);
|
||||
const sW = st?.getBoundingClientRect().width || 0;
|
||||
const sT = st ? getComputedStyle(st.querySelector('ha-icon')).transform : 'none';
|
||||
o.staticCarriesScale = sW > 0;
|
||||
// абсолютные px несравнимы (разные контейнеры/letterbox) — контракт в том,
|
||||
// что множитель РАБОТАЕТ на обеих карточках: базовый значок той же карточки
|
||||
// втрое меньше масштабированного
|
||||
const stBase = [...card.renderRoot.querySelectorAll('.dev')]
|
||||
.find((e) => !(e.getAttribute('style') || '').includes('--dev-scale'));
|
||||
const sBaseW = stBase?.getBoundingClientRect().width || 0;
|
||||
o.staticRatioIs3 = sBaseW > 0 && Math.abs(sW / sBaseW - 3) < 0.35;
|
||||
const fBase = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.dev')]
|
||||
.find((e) => !(e.getAttribute('style') || '').includes('--dev-scale') && !e.classList.contains('ghost'));
|
||||
const fBaseW = fBase?.getBoundingClientRect().width || 0;
|
||||
o.fullRatioIs3 = fBaseW > 0 && Math.abs(fW / fBaseW - 3) < 0.35;
|
||||
o.bothRotated = fT !== 'none' && sT !== 'none' && fT.startsWith('matrix') && sT.startsWith('matrix');
|
||||
if (!o.staticRatioIs3) console.log('static', sW, sBaseW);
|
||||
host.remove();
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== d.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
|
||||
// Шаг угла в диалоге устройства — 5°, а не 10° (владелец, 2026-08-03):
|
||||
// значок часто надо выровнять по стене, которая не лежит на сетке в 10°.
|
||||
const out3 = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => d.space === 'f1');
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
// строка «размер · угол»: два ползунка, второй — угол
|
||||
const rows = [...sr().querySelectorAll('.dialog .colorrow')];
|
||||
const row = rows.find((r) => r.textContent.includes('°'));
|
||||
const ctl = [...row.querySelectorAll('ha-slider, input[type=range]')];
|
||||
const stepOf = (el) => Number(el.step ?? el.getAttribute('step'));
|
||||
const maxOf = (el) => Number(el.max ?? el.getAttribute('max'));
|
||||
o.angleStepIs5 = ctl.length === 2 && stepOf(ctl[1]) === 5;
|
||||
o.angleReaches355 = maxOf(ctl[1]) === 355;
|
||||
o.sizeStepUnchanged = stepOf(ctl[0]) === 0.1;
|
||||
// и значение реально ложится на 5°
|
||||
ctl[1].value = 35;
|
||||
ctl[1].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
o.angle35Applied = c._markerDialog.angle === 35;
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out3));
|
||||
@@ -42,7 +42,7 @@ const res = await page.evaluate(async () => {
|
||||
out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic');
|
||||
out.atticAspect = attic?.aspect;
|
||||
out.atticSquare = attic?.aspect === undefined; // no per-space ratio any more
|
||||
out.atticSettings = attic?.settings;
|
||||
out.atticNoPlan = attic ? attic.plan_url === null : null;
|
||||
return out;
|
||||
@@ -55,7 +55,9 @@ checkAll(res, {
|
||||
"labels": ["Living room", "Kitchen", "Bedroom", "Hallway"],
|
||||
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
|
||||
"lqiFills": 0,
|
||||
"atticAspect": 1,
|
||||
"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},
|
||||
"atticSquare": true,
|
||||
// fill_mode 'glow' — the default for NEW spaces since v1.54 (owner call).
|
||||
// Existing spaces with an absent fill_mode still resolve to 'none'.
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "fill_mode": "glow", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||
return [nx * 1000, ny * 1000]; // square canvas (v1.48.0)
|
||||
}, [nx, ny]);
|
||||
const out = {};
|
||||
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool='split';});
|
||||
// living room (r1) has walls at y=0.05 which are NOT grid nodes; click near the wall
|
||||
// living room (r1) has walls at y=0.14 which are NOT grid nodes; click near the wall
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick living
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.052)); // near top wall (off grid)
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.142)); // near top wall (off grid)
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.58)); // near bottom wall
|
||||
out.pending = await page.evaluate(()=>!!window.__card._pendingSplit);
|
||||
out.dialog = await page.evaluate(()=>!!window.__card._roomDialog);
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
// Sun on the plan (docs/SUN.md): compass gating, window wedges on all four
|
||||
// walls, direction follows north_deg, night is empty, wedges clip to rooms,
|
||||
// day/night background vs static, per-space inheritance, cloud fading,
|
||||
// memoisation across hass ticks, editors stay clean.
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
const setSun = async (azimuth, elevation) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth, elevation },
|
||||
} } };
|
||||
await upd();
|
||||
};
|
||||
const setWeather = async (state) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'weather.home': {
|
||||
entity_id: 'weather.home', state, attributes: {},
|
||||
} } };
|
||||
await upd();
|
||||
};
|
||||
const cfg = () => c._serverCfg;
|
||||
// DEV-B701-01: local mutations bump _cfgEpoch (what _saveConfig() does
|
||||
// synchronously). The old helper bumped _cfgRev — a server ack that the
|
||||
// production save path does NOT produce until the debounced write lands —
|
||||
// and thereby masked the stale sun-ray cache.
|
||||
const touchCfg = () => { c._cfgEpoch++; };
|
||||
const litIds = () => (c._sunRaysCache?.rays || []).map((r) => r.openingId).sort();
|
||||
const domPolys = () => [...sr().querySelectorAll('.sunlayer polygon')];
|
||||
const stageStyle = () => sr().querySelector('.stage').getAttribute('style') || '';
|
||||
|
||||
// ---- the test plan: windows on all four OUTER walls + one interior ----
|
||||
const sp = cfg().spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 }, // top of r1
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 }, // right of r3
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 }, // bottom of r4
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 }, // left of r1
|
||||
{ id: 'wI', type: 'window', x: 0.55, y: 0.30, angle: 90, length: 0.08 }, // r1|r2 wall
|
||||
{ id: 'dS', type: 'door', x: 0.20, y: 0.86, angle: 0, length: 0.1 }, // doors never glow
|
||||
];
|
||||
touchCfg();
|
||||
|
||||
// 1) sun present + rays enabled, but NO north_deg anywhere → dead silence
|
||||
cfg().settings = { ...(cfg().settings || {}), sun_rays: true };
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.silentWithoutNorth = domPolys().length === 0;
|
||||
out.noDaynightWithoutNorth = !sr().querySelector('.stage.daynight');
|
||||
|
||||
// 2) compass set → morning east sun lights ONLY the east window
|
||||
cfg().settings.north_deg = 0;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.morningEast = JSON.stringify(litIds()) === '["wE"]';
|
||||
out.morningDomDrawn = domPolys().length > 0;
|
||||
|
||||
// 3) noon south → only the south window, wedge shorter than the morning one
|
||||
const eastLen = c._sunRaysCache?.rays?.[0]?.len ?? 0;
|
||||
await setSun(180, 60);
|
||||
out.noonSouth = JSON.stringify(litIds()) === '["wS"]';
|
||||
out.noonShorter = (c._sunRaysCache?.rays?.[0]?.len ?? 1e9) < eastLen;
|
||||
|
||||
// 4) evening west → only the west window; wedge stays inside its room r1
|
||||
await setSun(270, 4);
|
||||
out.eveningWest = JSON.stringify(litIds()) === '["wW"]';
|
||||
const ray = c._sunRaysCache?.rays?.[0] || { polys: [] };
|
||||
out.wedgeClippedToRoom = ray.polys.length > 0 && ray.polys.every((poly) => poly.every(([x, y]) =>
|
||||
x >= 40 - 0.5 && x <= 550 + 0.5 && y >= 140 - 0.5 && y <= 580 + 0.5));
|
||||
|
||||
// 5) night → nothing
|
||||
await setSun(90, -10);
|
||||
out.nightEmpty = domPolys().length === 0;
|
||||
|
||||
// 6) rotated compass: the same morning east sun now lights the NORTH window
|
||||
cfg().settings.north_deg = 90;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.rotatedCompass = JSON.stringify(litIds()) === '["wN"]';
|
||||
cfg().settings.north_deg = 0;
|
||||
touchCfg();
|
||||
|
||||
// 7) memoisation: an unrelated hass tick must NOT recompute the wedges
|
||||
await setSun(90, 5);
|
||||
const cacheBefore = c._sunRaysCache;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } }; // plain tick, sun unchanged
|
||||
await upd();
|
||||
out.memoSurvivesTick = c._sunRaysCache === cacheBefore;
|
||||
await setSun(91, 5);
|
||||
out.memoFollowsSun = c._sunRaysCache !== cacheBefore;
|
||||
|
||||
// 8) day/night background: daynight breathes, static does not
|
||||
await setSun(180, 60);
|
||||
out.staticBgAtNoon = !stageStyle().includes('background:#ffffff');
|
||||
cfg().settings.bg_mode = 'daynight';
|
||||
touchCfg();
|
||||
await setSun(180, 60);
|
||||
out.daynightNoonBg = stageStyle().includes('background:#ffffff');
|
||||
out.daynightClass = !!sr().querySelector('.stage.daynight');
|
||||
await setSun(180, -20);
|
||||
out.daynightNightBg = stageStyle().includes('background:#070c14');
|
||||
out.nightPlanDimmed = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
|
||||
cfg().settings.bg_mode = 'static';
|
||||
touchCfg();
|
||||
await setSun(180, -20);
|
||||
out.staticNightKeepsTheme = !stageStyle().includes('background:#070c14');
|
||||
delete cfg().settings.bg_mode;
|
||||
touchCfg();
|
||||
|
||||
// 9) per-space inheritance: the space override wins over the global flag
|
||||
sp.settings = { ...(sp.settings || {}), sun_rays: false };
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.spaceOverridesOff = domPolys().length === 0;
|
||||
delete sp.settings.sun_rays;
|
||||
// and a per-space compass override redirects the light
|
||||
sp.settings.north_deg = 90;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.spaceNorthOverride = JSON.stringify(litIds()) === '["wN"]';
|
||||
delete sp.settings.north_deg;
|
||||
touchCfg();
|
||||
|
||||
// 10) clouds: rain kills the wedges, cloudy only fades them
|
||||
cfg().settings.weather_entity = 'weather.home';
|
||||
touchCfg();
|
||||
await setWeather('pouring');
|
||||
await setSun(90, 5);
|
||||
out.rainKillsRays = domPolys().length === 0;
|
||||
await setWeather('cloudy');
|
||||
const stopCloudy = Number(sr().querySelector('.sunlayer ~ * stop, defs stop')?.getAttribute('stop-opacity')
|
||||
|| sr().querySelector('stop')?.getAttribute('stop-opacity'));
|
||||
out.cloudyFades = domPolys().length > 0 && Math.abs(stopCloudy - 0.3 * 0.4) < 1e-6;
|
||||
await setWeather('sunny');
|
||||
const stopClear = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
|
||||
out.clearFullAlpha = Math.abs(stopClear - 0.3) < 1e-6;
|
||||
delete cfg().settings.weather_entity;
|
||||
touchCfg();
|
||||
|
||||
// 11) feature off: without the flag nothing is drawn even with sun+north
|
||||
delete cfg().settings.sun_rays;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.offDrawsNothing = domPolys().length === 0;
|
||||
cfg().settings.sun_rays = true;
|
||||
cfg().settings.bg_mode = 'daynight';
|
||||
touchCfg();
|
||||
|
||||
// 12) editors stay clean: no wedges, no day/night canvas
|
||||
await setSun(90, 5);
|
||||
c._setMode('plan');
|
||||
await upd();
|
||||
out.editorNoRays = domPolys().length === 0;
|
||||
out.editorNoDaynight = !sr().querySelector('.stage.daynight');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
out.viewRaysBack = domPolys().length > 0;
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
// 13) the general-settings dialog: a REAL drag on the compass dial sets degrees
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._openSettingsDialog();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
|
||||
});
|
||||
const box = await page.evaluate(() => {
|
||||
const r = window.__card.shadowRoot.querySelector('.compass').getBoundingClientRect();
|
||||
return { cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
|
||||
});
|
||||
await page.mouse.move(box.cx + 45, box.cy); // on the ring, due right = 90°
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.cx, box.cy + 45); // drag on to due down = 180°
|
||||
await page.mouse.up();
|
||||
const dlg = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot;
|
||||
const out = {
|
||||
compassDragSets180: c._settingsDialog?.northDeg === 180,
|
||||
dialogHasModeSelect: !!sr.querySelector('.dialog select.areasel'),
|
||||
dialogHasRaysToggle: [...sr.querySelectorAll('.dialog .srcrow input[type=checkbox]')].length > 0,
|
||||
dialogHasWeatherList: sr.querySelectorAll('#hp-weather-list option').length > 0,
|
||||
dialogNumberMatches: sr.querySelector('.suncol input[type=number]')?.value === '180',
|
||||
};
|
||||
c._settingsDialog = null;
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, dlg);
|
||||
|
||||
// 14) DEV-B701-01 regression: the PRODUCTION local-save path (no touchCfg, no
|
||||
// fake rev/epoch bump from the test) must move the wedge. A REAL pointer drag
|
||||
// of the east window ends in _opPointerUp -> _saveConfig(); the WS write is
|
||||
// debounced 500 ms, so at check time _cfgRev is still the boot revision — the
|
||||
// ray must follow the window anyway, and again after the late server ack.
|
||||
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const c = window.__card;
|
||||
const stage = c.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const svgEl = stage.querySelector('svg');
|
||||
const [vx, vy, vw, vh] = svgEl.getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
const rayInfo = () => page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const rays = c._sunRaysCache?.rays || [];
|
||||
const r = rays.find((q) => q.openingId === 'wE');
|
||||
return {
|
||||
ids: rays.map((q) => q.openingId).sort(),
|
||||
midY: r ? (r.a[1] + r.b[1]) / 2 : NaN,
|
||||
minX: r ? Math.min(...r.polys.flat().map((p) => p[0])) : NaN,
|
||||
rev: c._cfgRev,
|
||||
wEy: c._serverCfg.spaces.find((s) => s.id === 'f1').openings.find((o) => o.id === 'wE').y,
|
||||
};
|
||||
});
|
||||
|
||||
// baseline: morning east sun, only wE glows, window sits at y = 600
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 90, elevation: 5 },
|
||||
} } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
});
|
||||
const base = await rayInfo();
|
||||
check('b701_baseline_east_only', base.ids, ['wE']);
|
||||
check('b701_baseline_mid_600', Math.abs(base.midY - 600) < 1, true);
|
||||
|
||||
// the real drag: enter the opening editor and pull wE down the east wall
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('plan'); c._tool = 'opening';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
const [dx, dy] = await screenPt(960, 600);
|
||||
const [, dty] = await screenPt(960, 700);
|
||||
await page.mouse.move(dx, dy);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(dx, dty, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
|
||||
// leave the editor IMMEDIATELY — well inside the 500 ms debounce window
|
||||
const after = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('view');
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
return true;
|
||||
});
|
||||
check('b701_left_editor', after);
|
||||
const moved = await rayInfo();
|
||||
check('b701_window_really_moved', Math.abs(moved.wEy * 1000 - 700) < 15, true);
|
||||
check('b701_rev_still_boot', moved.rev, base.rev); // the write has NOT landed yet
|
||||
check('b701_ray_follows_window', Math.abs(moved.midY - moved.wEy * 1000) < 1, true);
|
||||
check('b701_ray_not_stale', Math.abs(moved.midY - 600) > 50, true);
|
||||
|
||||
// same defect class for ROOM geometry: r3 shrinks to a thin strip along its
|
||||
// east wall through the real mutation endpoint (_saveConfig, still no ack).
|
||||
// The wedge reaches ~188 units into the room (minX ~772), so the new west
|
||||
// boundary at x = 900 MUST cut it — a stale clip keeps points near x = 772.
|
||||
const clipped = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.rooms.find((r) => r.id === 'r3').poly = [[0.90, 0.46], [0.96, 0.46], [0.96, 0.86], [0.90, 0.86]];
|
||||
c._saveConfig(); // the one true local-save entry point
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
return true;
|
||||
});
|
||||
check('b701_room_mutated', clipped);
|
||||
const reclipped = await rayInfo();
|
||||
check('b701_room_rev_still_boot', reclipped.rev, base.rev);
|
||||
check('b701_room_reclips_ray', reclipped.minX >= 900 - 1, true);
|
||||
|
||||
// let the debounced write land: the ray must survive the late server ack
|
||||
await page.waitForTimeout(800);
|
||||
const acked = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
return true;
|
||||
});
|
||||
check('b701_acked', acked);
|
||||
const final = await rayInfo();
|
||||
check('b701_write_landed', final.rev !== base.rev, true);
|
||||
check('b701_ray_survives_ack', Math.abs(final.midY - final.wEy * 1000) < 1, true);
|
||||
|
||||
// 15) the 3° threshold + the 2 s dissolve (owner 2026-08-03). Runs LAST and in
|
||||
// its own evaluate: it deliberately waits out a two-second fade, and the b701
|
||||
// regression above measures a debounced config write against wall-clock time.
|
||||
const thr = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
const setSun = async (azimuth, elevation) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth, elevation },
|
||||
} } };
|
||||
await upd();
|
||||
};
|
||||
const domPolys = () => [...sr().querySelectorAll('.sunlayer polygon')];
|
||||
const layer = () => sr().querySelector('.sunlayer');
|
||||
const stopAlpha = () => Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
|
||||
|
||||
// the old build ramped the alpha in over the first ~2°; now the layer is
|
||||
// either absent or at FULL strength, and crossing 3° fades the whole LAYER
|
||||
// (never the geometry) in or out over exactly two seconds
|
||||
await setSun(90, 10);
|
||||
out.aboveThresholdDrawn = domPolys().length > 0 && !layer().classList.contains('out');
|
||||
{
|
||||
const cs = getComputedStyle(layer());
|
||||
out.fadeInTakesTwoSeconds = cs.animationName === 'hp-sunfade-in' && cs.animationDuration === '2s';
|
||||
}
|
||||
out.fullAlphaWellAboveThreshold = Math.abs(stopAlpha() - 0.3) < 1e-6;
|
||||
await setSun(90, 3.2);
|
||||
out.fullAlphaJustAboveThreshold = Math.abs(stopAlpha() - 0.3) < 1e-6;
|
||||
|
||||
// below 3°: the layer stays for exactly the dissolve, then leaves the DOM
|
||||
await setSun(90, 2);
|
||||
{
|
||||
const dying = layer();
|
||||
const cs = dying && getComputedStyle(dying);
|
||||
out.belowThresholdDissolves = !!dying && dying.classList.contains('out')
|
||||
&& cs.animationName === 'hp-sunfade-out' && cs.animationDuration === '2s';
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2300));
|
||||
await upd();
|
||||
out.belowThresholdGoneAfterFade = !layer() && domPolys().length === 0;
|
||||
|
||||
// a sun that never crossed the threshold draws nothing at all — no ghost
|
||||
await setSun(90, 1);
|
||||
out.coldStartBelowThresholdEmpty = domPolys().length === 0 && !layer();
|
||||
await setSun(90, 5);
|
||||
out.backAboveThreshold = domPolys().length > 0 && !layer().classList.contains('out');
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, thr);
|
||||
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -0,0 +1,71 @@
|
||||
// Owner 2026-08-04: «цвет фона не меняется сам с течением времени суток, только
|
||||
// после обновления страницы». The day/night sky must follow sun.sun on a plain
|
||||
// hass tick — no reload, no requestUpdate() from the outside — and the COMPUTED
|
||||
// background of the stage (what the eye sees, not the style attribute) must
|
||||
// actually arrive at the sky colour (docs/SUN.md).
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const cfg = c._serverCfg;
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'daynight', sun_rays: true };
|
||||
cfg.spaces.find((s) => s.id === 'f1').openings = [
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
];
|
||||
c._cfgEpoch++;
|
||||
// NO requestUpdate anywhere below: production only ever assigns `hass`.
|
||||
const setSun = async (az, el) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth: az, elevation: el },
|
||||
} } };
|
||||
await c.updateComplete;
|
||||
// one paint so the sky lands on screen, not only in the attribute
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
};
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
const comp = () => getComputedStyle(stage()).backgroundColor;
|
||||
const dim = () => (sr().querySelector('.zoomwrap').getAttribute('style') || '');
|
||||
const polys = () => sr().querySelectorAll('.sunlayer polygon').length;
|
||||
const hexToRgb = (h) => 'rgb(' + [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(', ') + ')';
|
||||
|
||||
await setSun(180, 40);
|
||||
out.dayComputedWhite = comp() === 'rgb(255, 255, 255)';
|
||||
out.dayNoDim = dim().includes('brightness(1.000)');
|
||||
out.dayRays = polys() > 0;
|
||||
|
||||
// the whole point: a NEW hass, nothing else. No reload, no requestUpdate.
|
||||
await setSun(180, -10);
|
||||
out.nightComputedDark = comp() === 'rgb(10, 16, 25)';
|
||||
out.nightDimmed = dim().includes('brightness(0.900');
|
||||
out.nightNoRays = polys() === 0;
|
||||
|
||||
// ...and back, again on a plain tick
|
||||
await setSun(180, 40);
|
||||
out.backToDayComputed = comp() === 'rgb(255, 255, 255)';
|
||||
out.backToDayRays = polys() > 0;
|
||||
|
||||
// a small step still moves the sky (0.1° granularity of the model)...
|
||||
await setSun(180, 20);
|
||||
const twenty = comp();
|
||||
await setSun(180, 12);
|
||||
out.smallStepMovesSky = comp() !== twenty;
|
||||
|
||||
// ...but a step the size of a REAL sun update (~1° per 4 minutes) must still
|
||||
// GLIDE: the catch-up jump is for a card that was not watching, never for the
|
||||
// day/night breathing itself (docs/SUN.md).
|
||||
await setSun(180, 12.6);
|
||||
const target = stage().getAttribute('style').split('background:')[1];
|
||||
out.realStepNoSnapClass = !sr().querySelector('.stage.skysnap');
|
||||
out.realStepStillGlides = comp() !== hexToRgb(target); // 45 s transition, not a jump
|
||||
|
||||
// the tab came back from the background: the sky catches up at once
|
||||
await setSun(180, 25); // 12° of sun happened while we were not painting
|
||||
out.returnFromHiddenSnaps = comp() === hexToRgb(stage().getAttribute('style').split('background:')[1]);
|
||||
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -0,0 +1,124 @@
|
||||
// Owner 2026-08-04, on the first attempt: «с лучами солнца ты сделал фигню —
|
||||
// не надо размывать их боковые грани». A shaft of sunlight has HARD sides; it
|
||||
// fades only with distance, along the ray, from the glass inward. So:
|
||||
// * the reach is still 70 % of the v1.56 curve;
|
||||
// * the gradient still spans the FULL wedge and is dead from RAY_FADE_END;
|
||||
// * the sides are SHARP — no blur filter on the wedge, none defined at all;
|
||||
// * and because the sides are sharp, the wedge must end ON an iso-alpha line
|
||||
// of that gradient: nothing is ever drawn past its end, so the old bright
|
||||
// kerb (a far edge parallel to the wall, cut while still lit) cannot come
|
||||
// back at an oblique sun.
|
||||
// The "light never crosses a wall" clip is asserted in demo/smoke_sun.mjs
|
||||
// (wedgeClippedToRoom) — the polygons arrive from computeSunRays() already
|
||||
// intersected with the room, which is why no clip-path is needed here.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const cfg = c._serverCfg;
|
||||
const sp = cfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
];
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
|
||||
c._cfgEpoch++;
|
||||
const setSun = async (az, el) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: az, elevation: el } } } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
};
|
||||
const grads = () => [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
|
||||
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => [
|
||||
parseFloat(s.getAttribute('offset')), Number(s.getAttribute('stop-opacity'))]);
|
||||
|
||||
// ---- 1) 30 % shorter: the wedge reach in window lengths ----------------
|
||||
await setSun(270, 5); // low western sun into the west window
|
||||
const oldK = (e) => 0.8 + 1.7 * Math.pow(1 - e / 90, 1.6);
|
||||
const winLen = 0.08 * 1000;
|
||||
const low = c._sunRaysCache.rays[0].len;
|
||||
out.lowSunIs70Percent = Math.abs(low - oldK(5) * 0.7 * winLen) < 1e-6;
|
||||
await setSun(180, 60);
|
||||
const high = c._sunRaysCache.rays[0].len;
|
||||
out.highSunIs70Percent = Math.abs(high - oldK(60) * 0.7 * winLen) < 1e-6;
|
||||
out.lowStillReachesFurther = low > high;
|
||||
|
||||
// ---- 2) the shaft always dissolves BEFORE its own far edge -------------
|
||||
await setSun(270, 5);
|
||||
const gs = grads();
|
||||
out.gradientsDrawn = gs.length > 0;
|
||||
out.gradientSpansWholeWedge = gs.every((g) => {
|
||||
const st = stopsOf(g);
|
||||
// the gradient axis is the FULL wedge length (x1,y1 → x2,y2 = len away)
|
||||
const dx = +g.getAttribute('x2') - +g.getAttribute('x1');
|
||||
const dy = +g.getAttribute('y2') - +g.getAttribute('y1');
|
||||
return Math.abs(Math.hypot(dx, dy) - c._sunRaysCache.rays[0].len) < 1e-6 && st.length > 2;
|
||||
});
|
||||
out.deadWellBeforeTheEnd = gs.every((g) => {
|
||||
const st = stopsOf(g);
|
||||
const firstZero = st.find(([, a]) => a === 0);
|
||||
return !!firstZero && firstZero[0] <= 85.001; // %
|
||||
});
|
||||
out.lastStopIsZero = gs.every((g) => stopsOf(g).slice(-1)[0][1] === 0);
|
||||
out.brightAtTheGlass = gs.every((g) => stopsOf(g)[0][1] > 0.2);
|
||||
out.neverBrightensInward = gs.every((g) => {
|
||||
const st = stopsOf(g);
|
||||
return st.every(([, a], i) => i === 0 || a <= st[i - 1][1] + 1e-9);
|
||||
});
|
||||
|
||||
// ---- 3) SHARP sides: no blur on the wedge, and none defined anywhere ----
|
||||
const wedges = () => [...sr().querySelectorAll('.sunlayer polygon')];
|
||||
// walk the polygon and its ancestors up to and including .sunlayer — the
|
||||
// day/night `brightness` filter lives further up, on the zoomwrap, and is
|
||||
// none of this test's business
|
||||
const blurredChain = (el) => {
|
||||
for (let n = el; n; n = n.parentElement) {
|
||||
const attr = n.getAttribute && n.getAttribute('filter');
|
||||
if (attr && attr !== 'none') return true;
|
||||
const cs = getComputedStyle(n).filter;
|
||||
if (cs && cs !== 'none' && cs !== '') return true;
|
||||
if (n.classList && n.classList.contains('sunlayer')) break;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
out.wedgesDrawn = wedges().length > 0;
|
||||
out.everyWedgeHasSharpSides = wedges().length > 0 && wedges().every((p) => !blurredChain(p));
|
||||
out.noSoftFilterDefined = sr().querySelectorAll('filter[id^=hp-sunsoft-]').length === 0;
|
||||
out.noGaussianBlurAtAll = sr().querySelectorAll('feGaussianBlur').length === 0;
|
||||
|
||||
// ---- 4) an OBLIQUE sun: the shaft still dies of its gradient -----------
|
||||
// The sides are hard again, so the only thing that may end the wedge is the
|
||||
// gradient. That holds ONLY if the far edge is square to the RAY: with the
|
||||
// old wall-parallel edge one far corner sat at offset ~0.7 (low sun) or
|
||||
// ~0.11 (high sun) — i.e. still lit — which is exactly the bright kerb.
|
||||
const tOf = (r, p) => {
|
||||
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
|
||||
return ((p[0] - mx) * r.dir[0] + (p[1] - my) * r.dir[1]) / r.len;
|
||||
};
|
||||
const skew = (r) => {
|
||||
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
|
||||
return Math.abs((r.a[0] - mx) * r.dir[0] + (r.a[1] - my) * r.dir[1]) / r.len;
|
||||
};
|
||||
out.obliqueChecked = [];
|
||||
out.sunIsReallyOblique = true;
|
||||
out.nothingDrawnPastTheGradient = true;
|
||||
for (const [az, el] of [[230, 8], [225, 55]]) {
|
||||
await setSun(az, el);
|
||||
const rays = c._sunRaysCache.rays;
|
||||
out.obliqueChecked.push(rays.length);
|
||||
if (!rays.length) { out.sunIsReallyOblique = false; continue; }
|
||||
if (!rays.some((r) => skew(r) > 1e-3)) out.sunIsReallyOblique = false;
|
||||
for (const r of rays) {
|
||||
for (const poly of r.polys) {
|
||||
for (const p of poly) if (tOf(r, p) > 1 + 1e-6) out.nothingDrawnPastTheGradient = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.obliqueSunHasWedges = out.obliqueChecked.every((n) => n > 0);
|
||||
delete out.obliqueChecked;
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -0,0 +1,78 @@
|
||||
// HP-1454-01: загруженный SVG — пользовательский контент, который Home Assistant
|
||||
// отдаёт со своего origin. Внутри карточки он подключён через <image>, где
|
||||
// скрипты не выполняются, но тот же URL, открытый как отдельный документ,
|
||||
// становится живым документом этого origin: <script> в нём получает доступ к
|
||||
// localStorage сессии и к API. Проверяем, что заголовок sandbox это снимает,
|
||||
// и что обычный SVG при этом продолжает отображаться.
|
||||
import { chromium } from 'playwright';
|
||||
import { check, finish } from './serve.mjs';
|
||||
|
||||
const EVIL = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<rect width="100" height="100" fill="#eee"/>
|
||||
<script>
|
||||
document.title = 'HOUSEPLAN_XSS_EXECUTED';
|
||||
try { localStorage.setItem('hp_xss', 'executed'); } catch (e) {}
|
||||
</script>
|
||||
</svg>`;
|
||||
|
||||
// ровно тот набор, который отдаёт HouseplanContentView для .svg
|
||||
const CSP = "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
|
||||
+ "base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:";
|
||||
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const ctx = await browser.newContext();
|
||||
|
||||
async function serve(page, { csp }) {
|
||||
await page.route('**/*', (route) => {
|
||||
const url = route.request().url();
|
||||
if (url.endsWith('/evil.svg')) {
|
||||
const headers = { 'Content-Type': 'image/svg+xml', 'X-Content-Type-Options': 'nosniff' };
|
||||
if (csp) headers['Content-Security-Policy'] = CSP;
|
||||
return route.fulfill({ status: 200, headers, body: EVIL });
|
||||
}
|
||||
return route.fulfill({ status: 200, contentType: 'text/html', body: '<html><body>host</body></html>' });
|
||||
});
|
||||
}
|
||||
|
||||
// 1) как было до фикса: скрипт исполняется в origin Home Assistant
|
||||
const before = await ctx.newPage();
|
||||
await serve(before, { csp: false });
|
||||
await before.goto('https://ha.example/api/houseplan/content/plans/_/evil.svg');
|
||||
await before.waitForTimeout(200);
|
||||
const noCsp = await before.evaluate(() => ({
|
||||
title: document.title,
|
||||
storage: (() => { try { return localStorage.getItem('hp_xss'); } catch (e) { return 'blocked'; } })(),
|
||||
}));
|
||||
|
||||
// 2) с заголовком: opaque origin, скрипт не выполняется, storage недоступен
|
||||
const after = await ctx.newPage();
|
||||
await serve(after, { csp: true });
|
||||
await after.goto('https://ha.example/api/houseplan/content/plans/_/evil.svg');
|
||||
await after.waitForTimeout(200);
|
||||
const withCsp = await after.evaluate(() => ({
|
||||
title: document.title,
|
||||
storage: (() => { try { return localStorage.getItem('hp_xss'); } catch (e) { return 'blocked'; } })(),
|
||||
}));
|
||||
|
||||
// 3) тот же файл как <image> внутри страницы — рисуется и без скрипта
|
||||
const card = await ctx.newPage();
|
||||
await serve(card, { csp: true });
|
||||
await card.goto('https://ha.example/');
|
||||
const drawn = await card.evaluate(async () => {
|
||||
const img = new Image();
|
||||
const ok = await new Promise((res) => {
|
||||
img.onload = () => res(true);
|
||||
img.onerror = () => res(false);
|
||||
img.src = '/api/houseplan/content/plans/_/evil.svg';
|
||||
});
|
||||
return { loaded: ok, width: img.naturalWidth, title: document.title };
|
||||
});
|
||||
|
||||
check('без CSP скрипт выполняется (иначе тест ничего не доказывает)', noCsp.title, 'HOUSEPLAN_XSS_EXECUTED');
|
||||
check('без CSP скрипт пишет в storage origin', noCsp.storage, 'executed');
|
||||
check('с CSP скрипт не выполняется', withCsp.title !== 'HOUSEPLAN_XSS_EXECUTED', true);
|
||||
check('с CSP storage origin недоступен', withCsp.storage !== 'executed', true);
|
||||
check('SVG по-прежнему грузится как картинка', drawn.loaded, true);
|
||||
check('и имеет размеры', drawn.width, 100);
|
||||
check('картинка ничего не выполнила на странице-хосте', drawn.title, '');
|
||||
await finish(browser);
|
||||
@@ -12,7 +12,7 @@ const res = await page.evaluate(async () => {
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
const sel = [...sr().querySelectorAll('.dialog select')].find((s) =>
|
||||
[...s.options].some((o) => o.textContent === c._t('tap.toggle')));
|
||||
out.threeOptions = sel && sel.options.length === 3;
|
||||
out.threeOptions = sel && sel.options.length === 4; // + «Запустить…» (2026-07-29)
|
||||
out.noAutoOption = sel && ![...sel.options].some((o) => o.value === '');
|
||||
out.defaultInfo = sel && sel.value === 'info';
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// ТЗ 2026-07-29: действие по нажатию «Запустить автоматизацию/скрипт/сцену»
|
||||
// с пикером и поиском + чекбокс «Спрашивать подтверждение» для toggle/run.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass,
|
||||
states: { ...c.hass.states,
|
||||
'automation.evening': { state: 'on', attributes: { friendly_name: 'Вечерний свет' } },
|
||||
'script.curtains': { state: 'off', attributes: { friendly_name: 'Шторы' } },
|
||||
'scene.movie': { state: '', attributes: { friendly_name: 'Кино' } } },
|
||||
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; } };
|
||||
|
||||
// --- диалог: выбор run + пикер с поиском --------------------------------
|
||||
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(d); await c.updateComplete;
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'run' }; await c.updateComplete;
|
||||
// ВИДИМОСТЬ, не просто наличие в DOM: список — flex-item со скроллом и
|
||||
// однажды схлопнулся в полоску 1px при 26 кандидатах внутри (репорт
|
||||
// пользователя 2026-07-30, поймано только замером высоты)
|
||||
const listEl = () => sr().querySelector('.dialog .candlist');
|
||||
o.pickerShown = !!listEl() && listEl().getBoundingClientRect().height > 30;
|
||||
c._markerDialog = { ...c._markerDialog, runFilter: 'штор' }; await c.updateComplete;
|
||||
const cands = [...sr().querySelectorAll('.dialog .cand')].map((x) => x.textContent);
|
||||
o.searchWorks = cands.length >= 1 && cands.some((t) => t.includes('Шторы'));
|
||||
// и найденная строка реально нарисована, а не сплющена
|
||||
const row = sr().querySelector('.dialog .cand');
|
||||
o.resultRowVisible = !!row && row.getBoundingClientRect().height > 10
|
||||
&& row.getBoundingClientRect().bottom <= listEl().getBoundingClientRect().bottom + 1;
|
||||
// сохранение без цели блокируется
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
o.saveBlockedWithoutTarget = !!c._markerDialog;
|
||||
// выбираем цель + подтверждение, сохраняем
|
||||
c._markerDialog = { ...c._markerDialog, tapTarget: 'script.curtains', tapConfirm: true };
|
||||
await c.updateComplete;
|
||||
o.confirmCheckboxShown = [...sr().querySelectorAll('.dialog .srcrow')].length >= 2;
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:' + d.bindingRef);
|
||||
o.markerSaved = saved?.tap_action === 'run' && saved?.tap_target === 'script.curtains' && saved?.tap_confirm === true;
|
||||
|
||||
// --- тап: подтверждение → отмена → вызова нет ---------------------------
|
||||
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const dev = c._devices.find((x) => x.id === (saved?.id ?? d.id)) || c._devices.find((x) => x.bindingRef === d.bindingRef);
|
||||
c._clickDevice({ stopPropagation() {} }, dev); await c.updateComplete;
|
||||
o.confirmDialogShown = !!c._tapConfirm && c._tapConfirm.text.includes('Шторы');
|
||||
c._tapConfirm = null; await c.updateComplete;
|
||||
o.cancelNoCall = calls.length === 0;
|
||||
|
||||
// --- тап: подтвердили → script.turn_on ----------------------------------
|
||||
c._clickDevice({ stopPropagation() {} }, dev); await c.updateComplete;
|
||||
const conf = c._tapConfirm; c._tapConfirm = null; conf.exec();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.runCalled = calls.length === 1 && calls[0][0] === 'script' && calls[0][1] === 'turn_on'
|
||||
&& calls[0][2].entity_id === 'script.curtains';
|
||||
|
||||
// --- без подтверждения: сразу вызов; автоматизация → trigger ------------
|
||||
c._serverCfg.markers = c._serverCfg.markers.map((m) =>
|
||||
m.id === (saved?.id ?? d.id) ? { ...m, tap_target: 'automation.evening', tap_confirm: null } : m);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
const dev2 = c._devices.find((x) => x.bindingRef === d.bindingRef);
|
||||
calls.length = 0;
|
||||
c._clickDevice({ stopPropagation() {} }, dev2); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.automationTriggered = calls.length === 1 && calls[0][0] === 'automation' && calls[0][1] === 'trigger';
|
||||
o.noConfirmWhenOff = !c._tapConfirm;
|
||||
|
||||
// --- цель удалена → тост, вызова нет ------------------------------------
|
||||
const states2 = { ...c.hass.states }; delete states2['automation.evening'];
|
||||
c.hass = { ...c.hass, states: states2 };
|
||||
calls.length = 0;
|
||||
c._clickDevice({ stopPropagation() {} }, dev2); await c.updateComplete;
|
||||
o.missingTargetSafe = calls.length === 0 && !!c._toast;
|
||||
|
||||
// очистка
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== (saved?.id ?? d.id));
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -32,7 +32,7 @@ checkAll(res, {
|
||||
"cold": ["#4fc3f7", "transparent", "transparent", "transparent"],
|
||||
"hot": ["#ffd45c", "transparent", "transparent", "transparent"],
|
||||
"swapped": ["#66d17a", "transparent", "transparent", "transparent"],
|
||||
"dialogTempFields": 3,
|
||||
"dialogHiddenWhenNone": 1,
|
||||
"dialogTempFields": 4, // масштаб + мин/макс + компас пространства (docs/SUN.md)
|
||||
"dialogHiddenWhenNone": 2, // мин/макс скрыты; масштаб и компас остаются
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// Live vacuum P1 (docs/VACUUM.md): puck, trail, calibration, hidden rules.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// a vacuum device bound to space f1, dock placed by layout, plus a source
|
||||
// camera whose coords live in "robot mm": target = 0.02*x+? — we calibrate
|
||||
// via the 6-number matrix directly (0.5 scale: robot 0..2000 -> canvas 0..1000)
|
||||
const M = [0.5, 0, 0, 0, 0.5, 0];
|
||||
const mkAttrs = (x, y, extra) => ({ vacuum_position: { x, y, a: 45 }, map_name: 'm1', ...extra });
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } },
|
||||
'camera.robo_map': { state: 'idle', attributes: mkAttrs(600, 800) },
|
||||
} };
|
||||
const cfg = c._serverCfg;
|
||||
cfg.markers = cfg.markers || [];
|
||||
cfg.markers.push({ id: 'e_vacuum_robo', binding: 'entity:vacuum.robo', space: 'f1',
|
||||
vacuum: { source: 'camera.robo_map', calibration: { m1: M } } });
|
||||
c._layout['e_vacuum_robo'] = { s: 'f1', x: 0.1, y: 0.1 };
|
||||
c._regSignature = ''; c._setMode('view'); await c.updateComplete;
|
||||
|
||||
o.dockedNoPuck = !sr().querySelector('.vacpuck');
|
||||
o.baseMarkerThere = !!sr().querySelector('.dev');
|
||||
|
||||
// start cleaning -> puck appears at transformed coords, base marker stays
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } } } };
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 50));
|
||||
let puck = sr().querySelector('.vacpuck');
|
||||
o.puckAppears = !!puck;
|
||||
o.puckRound = puck && getComputedStyle(puck).borderRadius === '50%';
|
||||
// owner 2026-07-31: the base badge, but round and 20% smaller — same plate
|
||||
// vs a NEUTRAL badge: the robot's own base is yellow while cleaning
|
||||
const devEl = sr().querySelector('.dev:not(.on)');
|
||||
const pcs = getComputedStyle(puck), dcs = getComputedStyle(devEl);
|
||||
o.puckPlateMatchesDev = pcs.backgroundColor === dcs.backgroundColor
|
||||
&& pcs.borderTopColor === dcs.borderTopColor;
|
||||
o.puck80pct = Math.abs(puck.getBoundingClientRect().width
|
||||
- devEl.getBoundingClientRect().width * 0.8) < 2;
|
||||
o.noWedge = !sr().querySelector('.vacwedge'); // owner 2026-07-31: no heading arrow
|
||||
// the glyph is DEAD centre (owner report: it floated on the text baseline)
|
||||
const pr = puck.getBoundingClientRect();
|
||||
const ir = sr().querySelector('.vacpuck ha-icon').getBoundingClientRect();
|
||||
o.iconCentred = Math.abs(ir.left + ir.width / 2 - (pr.left + pr.width / 2)) < 0.75
|
||||
&& Math.abs(ir.top + ir.height / 2 - (pr.top + pr.height / 2)) < 0.75;
|
||||
o.baseStaysDuringCleaning = !!sr().querySelector('.dev');
|
||||
|
||||
// drive: two more points -> trail polyline grows; puck moves
|
||||
const p1 = puck.getBoundingClientRect();
|
||||
for (const [x, y] of [[900, 800], [900, 1100]]) {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: mkAttrs(x, y) } } };
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 30));
|
||||
}
|
||||
const trail = sr().querySelector('.vactrail polyline');
|
||||
// the trail LAGS one point behind the puck: after 3 telemetry points only
|
||||
// the first two are drawn — a segment never outruns the icon (owner)
|
||||
o.trailDrawn = !!trail && trail.getAttribute('points').split(' ').length === 2;
|
||||
o.trailScaledByMatrix = !!trail && trail.getAttribute('points').startsWith('300.0,400.0');
|
||||
o.trailBehindPuck = !!trail && !trail.getAttribute('points').includes('450.0,550.0');
|
||||
// a zoom/view change TELEPORTS the puck instead of gliding across the plan
|
||||
c._applyView(1.35); c.requestUpdate(); await c.updateComplete;
|
||||
o.zoomTeleports = sr().querySelector('.vacpuck').classList.contains('jump');
|
||||
c._applyView(1); c.requestUpdate(); await c.updateComplete;
|
||||
// casing pair: dark halo + light core with identical geometry — the trail
|
||||
// must survive any room fill underneath (owner 2026-07-31)
|
||||
const tcase = sr().querySelector('.vactrail .case');
|
||||
const tcore = sr().querySelector('.vactrail .core');
|
||||
o.trailCasing = !!tcase && !!tcore
|
||||
&& tcase.getAttribute('points') === tcore.getAttribute('points')
|
||||
&& parseFloat(getComputedStyle(tcase).strokeWidth) > parseFloat(getComputedStyle(tcore).strokeWidth)
|
||||
&& getComputedStyle(tcase).stroke.includes('0, 0, 0')
|
||||
&& getComputedStyle(tcore).stroke.includes('255, 255, 255');
|
||||
|
||||
|
||||
// trail off via marker option
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail = false;
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.trailToggleOff = !sr().querySelector('.vactrail');
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail = null;
|
||||
|
||||
// dock -> puck dissolves, trail lingers (linger window)
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } } } };
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete; await new Promise((r) => setTimeout(r, 30));
|
||||
o.puckGoneWhenDocked = !sr().querySelector('.vacpuck');
|
||||
// default mode 'cleaning': the trail HIDES the moment the run ends (owner)
|
||||
o.trailHiddenAfterDock = !sr().querySelector('.vactrail polyline');
|
||||
|
||||
// hidden marker renders neither puck nor trail
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } } } };
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').hidden = true;
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.hiddenNoPuck = !sr().querySelector('.vacpuck') && !sr().querySelector('.vactrail');
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').hidden = false;
|
||||
|
||||
// no calibration for the active map -> no puck (graceful degradation)
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: mkAttrs(900, 1100, { map_name: 'm2' }) } } };
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.unknownMapNoPuck = !sr().querySelector('.vacpuck');
|
||||
|
||||
// ---- server-side runs: current + one previous (owner 2026-07-31) ----
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'always';
|
||||
c._vacSrvTrails = { e_vacuum_robo: {
|
||||
current: { map_id: 'm1', started: 1, ended: null,
|
||||
points: [[700, 500], [900, 500], [1100, 640], [950, 1150]] },
|
||||
previous: { map_id: 'm1', started: 0, ended: 1,
|
||||
points: [[600, 1300], [800, 1300], [800, 1500]] },
|
||||
} };
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } },
|
||||
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 950, y: 1150, a: 0 }, map_name: 'm1' } } } };
|
||||
await c.updateComplete;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
|
||||
const prevG = sr().querySelector('.vactrail g.prev');
|
||||
o.srvPrevRunShown = !!prevG && prevG.querySelectorAll('polyline').length === 2
|
||||
&& prevG.querySelector('.case').getAttribute('points').split(' ').length === 3;
|
||||
const curLines = [...sr().querySelectorAll('.vactrail > polyline.case, .vactrail g:not(.prev) polyline.case')];
|
||||
const cur = curLines.find((x) => !x.closest('g.prev'));
|
||||
// 4 server points, the live tail trimmed while moving -> 3 rendered
|
||||
o.srvCurTrimmed = !!cur && cur.getAttribute('points').split(' ').length === 3;
|
||||
o.srvPrevFaded = !!prevG && getComputedStyle(prevG).opacity === '0.4';
|
||||
// the growing tip is glued to the puck by the rAF sampler
|
||||
const tip = sr().querySelector('line.tip');
|
||||
o.tipExists = !!tip;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const puckNow = sr().querySelector('.vacpuck').getBoundingClientRect();
|
||||
const svgT = sr().querySelector('.vactrail');
|
||||
const vbT = svgT.viewBox.baseVal; const sbT = svgT.getBoundingClientRect();
|
||||
const tx = sbT.left + ((parseFloat(tip.getAttribute('x2')) - vbT.x) / vbT.width) * sbT.width;
|
||||
const ty = sbT.top + ((parseFloat(tip.getAttribute('y2')) - vbT.y) / vbT.height) * sbT.height;
|
||||
o.tipGluedToPuck = Math.hypot(tx - (puckNow.left + puckNow.width / 2), ty - (puckNow.top + puckNow.height / 2)) < 12;
|
||||
// 'never' kills every line including the previous run
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'never';
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.neverHidesAll = !sr().querySelector('.vactrail');
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = null;
|
||||
c._vacSrvTrails = {};
|
||||
|
||||
// ---- the fit panel (drag + corner-stretch) end to end ----
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
const dev = c._devices.find((x) => x.id === 'e_vacuum_robo');
|
||||
o.fitDevFound = !!dev;
|
||||
// map m2 with rooms (bboxes) and a parked robot
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 500, y: 500, a: 0 }, map_name: 'm2',
|
||||
rooms: { 1: { name: 'Кухня', x0: 0, y0: 0, x1: 1000, y1: 800 },
|
||||
2: { name: 'Зал', x0: 0, y0: 800, x1: 1000, y1: 2000 } } } } } };
|
||||
await c.updateComplete;
|
||||
c._vacStartFit(dev); await c.updateComplete;
|
||||
o.fitOverlay = !!sr().querySelector('.vacfit');
|
||||
o.fitGhostRooms = sr().querySelectorAll('.vacfit polygon').length === 2;
|
||||
o.fitLabels = [...sr().querySelectorAll('.vacfit text')].map((t) => t.textContent.trim()).join('|') === 'Кухня|Зал';
|
||||
o.fitHandles = sr().querySelectorAll('.vacfithandle').length === 4;
|
||||
// REAL hit-tests: what would an actual click land on? Synthetic dispatch
|
||||
// bypasses pointer-events, and that let the untouchable overlay pass once.
|
||||
const hitAt = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
let n = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
while (n && n.shadowRoot) {
|
||||
const deeper = n.shadowRoot.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
if (!deeper || deeper === n) break;
|
||||
n = deeper;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
o.fitHandleHittable = hitAt(sr().querySelector('.vacfithandle'))?.classList?.contains('vacfithandle') === true;
|
||||
o.fitGhostHittable = !!hitAt(sr().querySelector('.vacfit polygon'))?.closest?.('.vacfit');
|
||||
o.fitBar = !!sr().querySelector('.vaccalbar');
|
||||
o.fitMirrorDefault = c._vacFit.p.mir === true;
|
||||
// drag: the ghost centre must follow; rotate: the centre must NOT move
|
||||
const before = { ...c._vacFit.p };
|
||||
const svgEl = sr().querySelector('.vacfit');
|
||||
const sb2 = svgEl.getBoundingClientRect();
|
||||
const fire = (type, x, y, target) => (target || svgEl).dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, pointerId: 7, clientX: x, clientY: y }));
|
||||
fire('pointerdown', sb2.left + sb2.width * 0.5, sb2.top + sb2.height * 0.5);
|
||||
fire('pointermove', sb2.left + sb2.width * 0.6, sb2.top + sb2.height * 0.55);
|
||||
fire('pointerup', sb2.left + sb2.width * 0.6, sb2.top + sb2.height * 0.55);
|
||||
await c.updateComplete;
|
||||
o.fitDragMoves = c._vacFit.p.ox !== before.ox && c._vacFit.p.oy !== before.oy;
|
||||
const centreBefore = (() => { const t = c._vacFit.p; const m = [t.s * (t.mir ? -1 : 1), 0, t.ox, 0, t.s, t.oy];
|
||||
return [m[0] * 500 + m[2], m[4] * 1000 + m[5]]; })();
|
||||
c._vacFitTurn({ rot: 90 }); await c.updateComplete;
|
||||
o.fitRotateKeepsCentre = c._vacFit.p.rot === 90;
|
||||
// corner-stretch scales
|
||||
const s0 = c._vacFit.p.s;
|
||||
const handle = sr().querySelector('.vacfithandle');
|
||||
const hb = handle.getBoundingClientRect();
|
||||
fire('pointerdown', hb.left + hb.width / 2, hb.top + hb.height / 2, handle);
|
||||
fire('pointermove', hb.left + hb.width / 2 + 60, hb.top + hb.height / 2 + 60);
|
||||
fire('pointerup', hb.left + hb.width / 2 + 60, hb.top + hb.height / 2 + 60);
|
||||
await c.updateComplete;
|
||||
o.fitCornerScales = Math.abs(c._vacFit.p.s - s0) > 1e-6;
|
||||
// save -> matrix stored for m2, panel closed, puck appears while cleaning
|
||||
c._vacFitSave(); await c.updateComplete;
|
||||
const savedM = c._serverCfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.calibration.m2;
|
||||
o.fitSavedMatrix = Array.isArray(savedM) && savedM.length === 6 && savedM.every(Number.isFinite);
|
||||
o.fitClosed = !c._vacFit && !sr().querySelector('.vacfit');
|
||||
o.oldWizardGone = typeof c._vacCalClick === 'undefined' && typeof c._vacStartWizard === 'undefined';
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 501, y: 501, a: 0 }, map_name: 'm2',
|
||||
rooms: {} } } } };
|
||||
await c.updateComplete;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
|
||||
o.puckAfterFit = !!sr().querySelector('.vacpuck');
|
||||
|
||||
return o;
|
||||
});
|
||||
|
||||
checkAll(out, {
|
||||
dockedNoPuck: true, baseMarkerThere: true, puckAppears: true, puckRound: true,
|
||||
puckPlateMatchesDev: true, puck80pct: true,
|
||||
noWedge: true, iconCentred: true, baseStaysDuringCleaning: true, trailDrawn: true,
|
||||
trailScaledByMatrix: true, trailBehindPuck: true, zoomTeleports: true,
|
||||
trailCasing: true, trailToggleOff: true,
|
||||
puckGoneWhenDocked: true, trailHiddenAfterDock: true, hiddenNoPuck: true,
|
||||
unknownMapNoPuck: true,
|
||||
srvPrevRunShown: true, srvCurTrimmed: true, srvPrevFaded: true,
|
||||
tipExists: true, tipGluedToPuck: true, neverHidesAll: true,
|
||||
fitDevFound: true, fitOverlay: true, fitGhostRooms: true, fitLabels: true,
|
||||
fitHandles: true, fitHandleHittable: true, fitGhostHittable: true, fitBar: true, fitMirrorDefault: true, fitDragMoves: true,
|
||||
fitRotateKeepsCentre: true, fitCornerScales: true, fitSavedMatrix: true,
|
||||
fitClosed: true, oldWizardGone: true, puckAfterFit: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,108 @@
|
||||
// v1.54.0 audit regressions (HP-1540-01/-04/-06): the FIRST-USE path.
|
||||
// An auto-discovered vacuum has NO marker in the config — cfg.markers stays
|
||||
// empty here, unlike smoke_vacuum.mjs which always pushes one by hand (the
|
||||
// audit called that gap out explicitly). Every vacuum action must materialise
|
||||
// the marker and actually persist; plan rooms are legacy RECTANGLES.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const cfg = c._serverCfg;
|
||||
|
||||
// legacy rectangle rooms (x/y/w/h, HP-1540-04) in the garden space where
|
||||
// the auto-discovered vacuum.mower already lives
|
||||
const g = cfg.spaces.find((s) => s.id === 'garden');
|
||||
g.rooms = [
|
||||
{ id: 'ga', name: 'Кухня', area: 'garden', x: 0.05, y: 0.10, w: 0.25, h: 0.30 },
|
||||
{ id: 'gb', name: 'Зал', x: 0.40, y: 0.10, w: 0.25, h: 0.30 },
|
||||
{ id: 'gc', name: 'Спальня', x: 0.05, y: 0.50, w: 0.25, h: 0.30 },
|
||||
];
|
||||
// robot rooms: same centres in "robot mm" (scale 4), map_index is a
|
||||
// NUMERIC ZERO — the cross-runtime zero-map contract (HP-1540-02)
|
||||
const robotRooms = {
|
||||
1: { name: 'Кухня', x0: 300, y0: 600, x1: 1100, y1: 1400 },
|
||||
2: { name: 'Зал', x0: 1700, y0: 600, x1: 2500, y1: 1400 },
|
||||
3: { name: 'Спальня', x0: 300, y0: 2200, x1: 1100, y1: 3000 },
|
||||
};
|
||||
const camAttrs = { vacuum_position: { x: 700, y: 1000, a: 0 }, map_index: 0, rooms: robotRooms };
|
||||
c.hass = { ...c.hass,
|
||||
entities: { ...c.hass.entities,
|
||||
'camera.mower_map': { entity_id: 'camera.mower_map', device_id: 'd_mower', platform: 'demo' } },
|
||||
states: { ...c.hass.states,
|
||||
'camera.mower_map': { state: 'idle', attributes: camAttrs } } };
|
||||
c._regSignature = '';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// spy on config writes: a success toast is only honest after one of these
|
||||
const writes = [];
|
||||
const realWS = c.hass.callWS;
|
||||
c.hass.callWS = async (m) => { if (m.type === 'houseplan/config/set') writes.push(m); return realWS(m); };
|
||||
|
||||
const dev = c._devices.find((x) => x.id === 'd_mower');
|
||||
o.devFound = !!dev;
|
||||
o.freshNoMarker = (cfg.markers || []).length === 0 && !dev.marker;
|
||||
|
||||
// ---- auto-calibration from scratch (HP-1540-01 + HP-1540-04) ----
|
||||
c._vacAutoCalibrate(dev);
|
||||
await c.updateComplete;
|
||||
const m1 = (cfg.markers || []).find((x) => x.id === 'd_mower');
|
||||
o.markerMaterialised = !!m1 && m1.binding === 'device:d_mower';
|
||||
const cal0 = m1?.vacuum?.calibration?.['0'];
|
||||
o.rectRoomsCalibrated = Array.isArray(cal0) && cal0.length === 6 && cal0.every(Number.isFinite);
|
||||
o.sourceStored = m1?.vacuum?.source === 'camera.mower_map';
|
||||
// the matrix really maps robot mm → canvas: centre of «Кухня» → (175, 250)
|
||||
const ap = (mm, x, y) => [mm[0] * x + mm[1] * y + mm[2], mm[3] * x + mm[4] * y + mm[5]];
|
||||
const hit = cal0 ? ap(cal0, 700, 1000) : [0, 0];
|
||||
o.matrixMapsRooms = Math.hypot(hit[0] - 175, hit[1] - 250) < 2;
|
||||
o.successToastShown = (c._toast || '').startsWith('Done: bound via 3 rooms');
|
||||
await new Promise((r) => setTimeout(r, 700)); // debounced _saveConfig
|
||||
o.configWritePersisted = writes.length >= 1
|
||||
&& !!writes[writes.length - 1].config.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'];
|
||||
|
||||
// ---- manual fit from scratch (HP-1540-01) ----
|
||||
cfg.markers = [];
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
const dev2 = c._devices.find((x) => x.id === 'd_mower');
|
||||
o.fitFreshNoMarker = !dev2.marker;
|
||||
c._vacStartFit(dev2); await c.updateComplete;
|
||||
c._vacFitSave(); await c.updateComplete;
|
||||
const m2 = (cfg.markers || []).find((x) => x.id === 'd_mower');
|
||||
const calFit = m2?.vacuum?.calibration?.['0'];
|
||||
o.fitMaterialises = !!m2 && Array.isArray(calFit) && calFit.length === 6;
|
||||
o.fitToastAfterSave = c._toast === c._t('vac.cal_done');
|
||||
|
||||
// ---- live checkbox + trail select from scratch (HP-1540-01) ----
|
||||
cfg.markers = [];
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(c._devices.find((x) => x.id === 'd_mower')); await c.updateComplete;
|
||||
const liveBox = sr().querySelector('.vacbox input[type=checkbox]');
|
||||
o.vacSectionShown = !!liveBox;
|
||||
if (liveBox) { liveBox.click(); await c.updateComplete; }
|
||||
const m3 = (cfg.markers || []).find((x) => x.id === 'd_mower');
|
||||
o.liveToggleMaterialises = !!m3 && m3.vacuum?.live === false;
|
||||
const sel = sr().querySelector('.vacbox select');
|
||||
if (sel) { sel.value = 'always'; sel.dispatchEvent(new Event('change')); await c.updateComplete; }
|
||||
o.trailModePersists = m3?.vacuum?.trail_mode === 'always';
|
||||
c._markerDialog = null; c._setMode('view'); await c.updateComplete;
|
||||
|
||||
// ---- error copy never points at the removed point calibration (HP-1540-06) ----
|
||||
const toasts = ['vac.autocal_no_rooms', 'vac.autocal_no_match', 'vac.autocal_res_warn']
|
||||
.map((k) => c._t(k)).join(' | ');
|
||||
o.noPointCalibrationCopy = !/point|точк/i.test(toasts);
|
||||
// and the real no-rooms path shows the reworded toast
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.mower_map': { state: 'idle', attributes: { vacuum_position: { x: 1, y: 2, a: 0 }, map_index: 0 } } } };
|
||||
await c.updateComplete;
|
||||
c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
|
||||
o.noRoomsToastReworded = c._toast === c._t('vac.autocal_no_rooms') && !/point|точк/i.test(c._toast);
|
||||
|
||||
c.hass.callWS = realWS;
|
||||
return o;
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,119 @@
|
||||
// DEV-B703-01: Lovelace re-creates card elements when the websocket reconnects
|
||||
// after a long-backgrounded tab; the fresh instance used to run the whole
|
||||
// first-open boot (veil + 700 ms + quiescence) — the owner's «план
|
||||
// перезагружается при возврате на вкладку». Within one loaded page a repeat
|
||||
// instance must open WARM: no veil, no wait, not a single frame at a stale
|
||||
// stage height or a default zoom. A window resize between instances
|
||||
// invalidates the memo → the full protective boot is back.
|
||||
// FAILS on the build before DEV-B703-01 (every instance booted cold).
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // deterministic: the page starts cold
|
||||
localStorage.setItem('houseplan_card_zoom_v1', JSON.stringify({ f1: 1.8 }));
|
||||
localStorage.removeItem('houseplan_card_nav_v1');
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
document.body.appendChild(wrap);
|
||||
const mk = () => {
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__mkHass();
|
||||
wrap.appendChild(c);
|
||||
return c;
|
||||
};
|
||||
|
||||
// ---- cold boot: the first instance pays the full protective window ----
|
||||
const c1 = mk();
|
||||
out.coldBoots = c1._booting === true;
|
||||
const t0 = performance.now();
|
||||
let veilSeen = false;
|
||||
while (c1._booting && performance.now() - t0 < 2500) {
|
||||
if ((c1.shadowRoot || c1.renderRoot).querySelector('.bootveil')) veilSeen = true;
|
||||
await sleep(30);
|
||||
}
|
||||
out.coldVeilSeen = veilSeen;
|
||||
out.coldSettled = c1._booting === false;
|
||||
await sleep(300); // fade out + soft grace start
|
||||
const stage1 = (c1.shadowRoot || c1.renderRoot).querySelector('.stage');
|
||||
const hFinal = stage1.clientHeight;
|
||||
const zoomSaved = c1._zoom;
|
||||
out.zoomArmed = Math.abs(zoomSaved - 1.8) < 0.01;
|
||||
c1.remove(); // Lovelace throws the old element away…
|
||||
await sleep(30);
|
||||
|
||||
// ---- …and creates a new one: same page, same viewport → WARM mount ----
|
||||
const c2 = mk();
|
||||
out.warmNoBootFlag = c2._booting === false; // decided synchronously in setConfig
|
||||
const sr2 = () => c2.shadowRoot || c2.renderRoot;
|
||||
const frames = [];
|
||||
const t1 = performance.now();
|
||||
await new Promise((done) => {
|
||||
const sample = () => {
|
||||
const stage = sr2().querySelector('.stage');
|
||||
if (stage) {
|
||||
const zw = stage.querySelector(':scope > .zoomwrap');
|
||||
frames.push({
|
||||
t: Math.round(performance.now() - t1),
|
||||
veil: !!sr2().querySelector('.bootveil'),
|
||||
planVisible: !!zw && getComputedStyle(zw).visibility === 'visible',
|
||||
rooms: stage.querySelectorAll('.room').length,
|
||||
stageH: stage.clientHeight,
|
||||
zoom: c2._zoom,
|
||||
});
|
||||
}
|
||||
if (performance.now() - t1 < 900) requestAnimationFrame(sample);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
out.framesSampled = frames.length > 20;
|
||||
out.warmNoVeilEver = !frames.some((f) => f.veil);
|
||||
out.warmPlanVisibleEveryFrame = frames.length > 0 && frames.every((f) => f.planVisible)
|
||||
? true : 'a frame hid the plan';
|
||||
const badH = frames.filter((f) => Math.abs(f.stageH - hFinal) > 2);
|
||||
out.warmNoStaleHeightFrame = badH.length === 0
|
||||
? true : `stage h=${badH[0].stageH} vs final ${hFinal} at t=${badH[0].t}ms`;
|
||||
out.warmZoomEveryFrame = frames.every((f) => Math.abs(f.zoom - zoomSaved) < 0.01)
|
||||
? true : 'a frame at a non-saved zoom';
|
||||
// the server config reload on a warm mount must not blank the plan
|
||||
out.warmNeverEmptyPlan = frames.every((f) => f.rooms > 0) ? true : 'a frame rendered no rooms';
|
||||
c2.remove();
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(res)) check(k, v);
|
||||
|
||||
// ---- a window resize between instances → memo stale → the full boot ----
|
||||
await page.setViewportSize({ width: 900, height: 680 });
|
||||
const rez = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__mkHass();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.appendChild(c);
|
||||
document.body.appendChild(wrap);
|
||||
out.resizedBootsCold = c._booting === true;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const t0 = performance.now();
|
||||
let veil = false;
|
||||
while (c._booting && performance.now() - t0 < 2500) {
|
||||
if (sr().querySelector('.bootveil')) veil = true;
|
||||
await sleep(30);
|
||||
}
|
||||
out.resizedVeilShown = veil;
|
||||
out.resizedSettles = c._booting === false;
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(rez)) check(k, v);
|
||||
|
||||
await finish(browser, { ...res, ...rez });
|
||||
@@ -0,0 +1,80 @@
|
||||
// DEV-B703-02: temporary WS failures must NEVER blank a plan that was already
|
||||
// shown. On the build before the fix _loadFromServer's catch nulled
|
||||
// _serverCfg after 8 failed tries — the plan disappeared even though a valid
|
||||
// config (the LS snapshot) was on screen. Stale-while-revalidate: the last
|
||||
// valid config lives until a successful reload replaces it, and once the
|
||||
// socket recovers the card revalidates QUIETLY on its own retry clock
|
||||
// (willUpdate stops driving loads after 8 tries).
|
||||
// planSurvivesOutage + reloadedAfterRecovery FAIL on the pre-fix build.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.();
|
||||
// the LS snapshot is in place (the page's main card cached it): the new
|
||||
// instance renders instantly from it — a wall tablet rejoining wifi
|
||||
out.lsCachePresent = !!localStorage.getItem('houseplan_card_cfg_v1');
|
||||
|
||||
let fail = true;
|
||||
let failedCalls = 0;
|
||||
const base = window.__mkHass();
|
||||
const mkFlaky = () => ({
|
||||
...base,
|
||||
callWS: async (m) => {
|
||||
if (fail) { failedCalls++; throw new Error('ws down'); }
|
||||
return base.callWS(m);
|
||||
},
|
||||
});
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = mkFlaky();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.appendChild(c);
|
||||
document.body.appendChild(wrap);
|
||||
await sleep(50);
|
||||
out.rendersFromCache = !!c._serverCfg;
|
||||
|
||||
// hass keeps ticking while the socket is down — drive past the 8-try budget
|
||||
for (let i = 0; i < 12; i++) {
|
||||
c.hass = mkFlaky();
|
||||
await sleep(60);
|
||||
}
|
||||
out.wsFailedEnough = failedCalls >= 8 ? true : `only ${failedCalls} failed calls`;
|
||||
out.planSurvivesOutage = c._serverCfg ? true : '_serverCfg was cleared by the outage';
|
||||
out.stillServerMode = c._serverStorage === true;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stage = sr().querySelector('.stage');
|
||||
out.roomsStillRendered = !!stage && stage.querySelectorAll('.room').length > 0;
|
||||
|
||||
// ---- the socket comes back: quiet self-driven revalidation ----
|
||||
fail = false;
|
||||
const t0 = performance.now();
|
||||
while (!c._loadOk && performance.now() - t0 < 12000) await sleep(100);
|
||||
out.reloadedAfterRecovery = c._loadOk === true ? true : 'never revalidated after recovery';
|
||||
out.cfgValidAfterRecovery = !!c._serverCfg && Array.isArray(c._serverCfg.spaces)
|
||||
&& c._serverCfg.spaces.length > 0;
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(res)) check(k, v);
|
||||
|
||||
// ---- a LIVE instance: a failing config reload keeps the last config ----
|
||||
const live = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const before = c._serverCfg;
|
||||
const origHass = c.hass;
|
||||
c.hass = { ...c.hass, callWS: async () => { throw new Error('ws down'); } };
|
||||
await c._reloadConfigOnly(true);
|
||||
out.liveReloadFailKeepsCfg = c._serverCfg === before
|
||||
? true : 'a failed reload replaced/cleared the live config';
|
||||
c.hass = origHass;
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(live)) check(k, v);
|
||||
|
||||
await finish(browser, { ...res, ...live });
|
||||
@@ -0,0 +1,60 @@
|
||||
// Единый принцип жёлтого (2026-07-29): жёлтый = устройство прямо сейчас
|
||||
// выполняет основную функцию. Термоголовка желтеет от реального нагрева
|
||||
// (hvac_action), а не от служебного свитча защиты от накипи; горящая лампа
|
||||
// желтит значок в любом режиме заливки тем же условием, что зажигает пятно.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(() => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const cls = (d, states) => {
|
||||
const saved = c.hass;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
|
||||
const r = c._stateClass(d);
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
const trv = { id: 't', primary: 'climate.trv', entities: ['climate.trv', 'switch.trv_anti_scaling'], marker: null };
|
||||
|
||||
// реально греет → жёлтая
|
||||
o.heatingIsYellow = cls(trv, {
|
||||
'climate.trv': { state: 'heat', attributes: { hvac_action: 'heating' } },
|
||||
'switch.trv_anti_scaling': { state: 'on' },
|
||||
}) === 'on';
|
||||
|
||||
// включена, но не греет (idle) → чёрная, даже со включённой защитой от накипи
|
||||
o.idleIsDark = cls(trv, {
|
||||
'climate.trv': { state: 'heat', attributes: { hvac_action: 'idle' } },
|
||||
'switch.trv_anti_scaling': { state: 'on' },
|
||||
}) === '';
|
||||
|
||||
// выключена → чёрная
|
||||
o.offIsDark = cls(trv, {
|
||||
'climate.trv': { state: 'off', attributes: { hvac_action: 'off' } },
|
||||
}) === '';
|
||||
|
||||
// без hvac_action фолбэк на state
|
||||
o.stateFallback = cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === ''
|
||||
? false : cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === 'on';
|
||||
|
||||
// горящая лампа устройства желтит значок, даже если primary — не она
|
||||
const lamp = { id: 'l', primary: 'sensor.lamp_power', entities: ['sensor.lamp_power', 'light.lamp'], marker: null };
|
||||
o.litLightWins = cls(lamp, {
|
||||
'sensor.lamp_power': { state: '5' },
|
||||
'light.lamp': { state: 'on' },
|
||||
}) === 'on';
|
||||
o.darkLampIdle = cls(lamp, {
|
||||
'sensor.lamp_power': { state: '5' },
|
||||
'light.lamp': { state: 'off' },
|
||||
}) === '';
|
||||
|
||||
// «источник света»: умный выключатель с глупыми светильниками — жёлтый от
|
||||
// того же условия, что и пятно glow
|
||||
const wall = { id: 'w', primary: 'sensor.w', entities: ['sensor.w'],
|
||||
marker: { is_light: true, controls: ['switch.fixtures'] } };
|
||||
o.forcedSourceYellow = cls(wall, {
|
||||
'sensor.w': { state: '1' }, 'switch.fixtures': { state: 'on' },
|
||||
}) === 'on';
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,110 @@
|
||||
// HP-1551: opening the card with a saved per-space zoom must NOT paint even a
|
||||
// single frame at the default fit ("the plan flashes and re-scales"). Repro of
|
||||
// the field report: LS holds the config cache (instant render) and a saved
|
||||
// zoom of 1.8, the server config arrives ~350 ms later like a real HA
|
||||
// websocket round-trip. Every rAF frame from the first possible moment is
|
||||
// sampled: a frame where the stage is visible AND the viewBox is at the
|
||||
// default scale is the flash.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
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' };
|
||||
const ZOOM = 1.8;
|
||||
|
||||
// ---- phase 1: prime the config cache exactly the way production does ------
|
||||
const { page, browser } = await launch();
|
||||
await page.waitForFunction(() => !!localStorage.getItem('houseplan_card_cfg_v1'), { timeout: 9000 });
|
||||
const cfgCache = await page.evaluate(() => localStorage.getItem('houseplan_card_cfg_v1'));
|
||||
// default fit width at zoom 1 (clean LS): the yardstick for "default scale"
|
||||
const defaultFitW = await page.evaluate(() => window.__card._view.w);
|
||||
await page.close();
|
||||
|
||||
// ---- phase 2: cold open with a saved zoom and a slow server ----------------
|
||||
const ctx = await browser.newContext({ viewport: { width: 820, height: 760 } });
|
||||
await ctx.route('**/*', (r) => {
|
||||
const u = new URL(r.request().url());
|
||||
let p = decodeURIComponent(u.pathname);
|
||||
if (p === '/') p = '/demo.html';
|
||||
const f = ROOT + p;
|
||||
existsSync(f)
|
||||
? r.fulfill({ status: 200, headers: { 'content-type': CT[p.slice(p.lastIndexOf('.'))] || 'application/octet-stream' }, body: readFileSync(f) })
|
||||
: r.fulfill({ status: 404, body: 'nf' });
|
||||
});
|
||||
await ctx.addInitScript(([cache, zoom]) => {
|
||||
localStorage.setItem('houseplan_card_cfg_v1', cache);
|
||||
localStorage.setItem('houseplan_card_zoom_v1', JSON.stringify({ f1: zoom }));
|
||||
localStorage.removeItem('houseplan_card_nav_v1');
|
||||
window.__frames = [];
|
||||
const origCreate = Document.prototype.createElement;
|
||||
Document.prototype.createElement = function (tag, ...a) {
|
||||
const el = origCreate.call(this, tag, ...a);
|
||||
if (String(tag).toLowerCase() !== 'houseplan-card' || window.__hooked) return el;
|
||||
window.__hooked = true;
|
||||
// model the HA websocket latency: the config cache renders instantly,
|
||||
// the live config lands 350 ms later
|
||||
let proto = Object.getPrototypeOf(el), desc = null;
|
||||
while (proto && !desc) { desc = Object.getOwnPropertyDescriptor(proto, 'hass'); proto = Object.getPrototypeOf(proto); }
|
||||
if (desc?.set) {
|
||||
Object.defineProperty(el, 'hass', {
|
||||
configurable: true,
|
||||
get() { return desc.get.call(this); },
|
||||
set(v) {
|
||||
if (v && typeof v.callWS === 'function' && !v.__slow) {
|
||||
const orig = v.callWS.bind(v);
|
||||
v.__slow = true;
|
||||
v.callWS = (m) => (m && m.type === 'houseplan/config/get')
|
||||
? new Promise((res) => setTimeout(() => res(orig(m)), 350))
|
||||
: orig(m);
|
||||
}
|
||||
desc.set.call(this, v);
|
||||
},
|
||||
});
|
||||
}
|
||||
// rAF sampling from the first possible moment (before the first paint)
|
||||
const sample = () => {
|
||||
const sr = el.shadowRoot || el.renderRoot;
|
||||
const stage = sr && sr.querySelector('.stage');
|
||||
const svgEl = stage && stage.querySelector(':scope > .zoomwrap > svg');
|
||||
if (stage && svgEl) {
|
||||
const cs = getComputedStyle(stage);
|
||||
const cw = getComputedStyle(svgEl.parentElement);
|
||||
const cv = getComputedStyle(svgEl);
|
||||
const r = stage.getBoundingClientRect();
|
||||
const vb = (svgEl.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
||||
const hidden = [cs, cw, cv].some((c) => c.opacity === '0' || c.visibility === 'hidden' || c.display === 'none');
|
||||
window.__frames.push({ visible: !hidden && r.width > 1 && r.height > 1, vbW: vb[2] || 0, zoom: el._zoom });
|
||||
} else {
|
||||
window.__frames.push(null);
|
||||
}
|
||||
if (window.__frames.length < 75) requestAnimationFrame(sample);
|
||||
else window.__framesDone = true;
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
return el;
|
||||
};
|
||||
}, [cfgCache, ZOOM]);
|
||||
|
||||
const p2 = await ctx.newPage();
|
||||
p2.on('pageerror', (e) => console.log('EXC2', e.message));
|
||||
await p2.goto('http://demo.local/demo.html', { waitUntil: 'domcontentloaded' });
|
||||
await p2.waitForFunction(() => window.__framesDone === true, { timeout: 15000 });
|
||||
|
||||
const frames = await p2.evaluate(() => window.__frames);
|
||||
const targetW = defaultFitW / ZOOM;
|
||||
const seen = frames.filter(Boolean);
|
||||
// the flash: stage visible while the viewBox is still at the default scale
|
||||
// (clearly wider than the saved-zoom view; the default fit is 1.8x wider)
|
||||
const flashFrames = seen.filter((f) => f.visible && f.vbW > targetW * 1.35);
|
||||
const last = seen[seen.length - 1];
|
||||
const goodFrames = seen.filter((f) => f.visible && Math.abs(f.vbW - targetW) < targetW * 0.02);
|
||||
|
||||
check('sampledFrames', seen.length > 30);
|
||||
check('noDefaultScaleFlash', flashFrames.length === 0 ? true
|
||||
: `flash: ${flashFrames.length} visible frame(s) at default scale, e.g. vbW=${flashFrames[0].vbW.toFixed(3)} vs target ${targetW.toFixed(3)}`);
|
||||
check('settledAtSavedZoom', !!last && last.visible && Math.abs(last.zoom - ZOOM) < 0.01
|
||||
&& Math.abs(last.vbW - targetW) < targetW * 0.02);
|
||||
check('planActuallyShown', goodFrames.length >= 5); // the fix must not just hide the plan forever
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,227 @@
|
||||
// v1.49.x: zoom goes below the base fit, the editor does not shift the plan.
|
||||
// - the stage height follows the MEASURED header, not a hard-coded 118px, so
|
||||
// entering an editor keeps the plan inside the viewport;
|
||||
// - zoom < 1 centres the content instead of pinning it to a corner;
|
||||
// - the content frame (default zoom) includes devices standing outside rooms.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
// -- editor entry keeps the stage inside the viewport --------------------
|
||||
const stageBox = () => page.evaluate(() => {
|
||||
const sr = window.__card.shadowRoot || window.__card.renderRoot;
|
||||
const b = sr.querySelector('.stage').getBoundingClientRect();
|
||||
return { top: Math.round(b.top), bottom: Math.round(b.bottom) };
|
||||
});
|
||||
const vh = await page.evaluate(() => window.innerHeight);
|
||||
const inView = await stageBox();
|
||||
await page.evaluate(() => window.__card._setMode('plan'));
|
||||
await page.waitForTimeout(400);
|
||||
const inPlan = await stageBox();
|
||||
out.viewFitsViewport = inView.bottom <= vh + 2;
|
||||
out.editorFitsViewport = inPlan.bottom <= vh + 2; // used to overflow by ~90px
|
||||
out.editorStageShrinks = inPlan.top > inView.top && inPlan.bottom <= inView.bottom + 2;
|
||||
await page.evaluate(() => window.__card._setMode('view'));
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// -- zoom out below the base fit -----------------------------------------
|
||||
out.zoomOut = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._resetZoom();
|
||||
const fit = { ...c._viewOr(c._baseVb()) };
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
c._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 0.5);
|
||||
const v = { ...c._view };
|
||||
const base = c._baseVb();
|
||||
const cx = v.x + v.w / 2, cy = v.y + v.h / 2;
|
||||
return {
|
||||
zoom: c._zoom,
|
||||
wider: v.w > fit.w * 1.9, // actually zoomed out
|
||||
centredX: Math.abs(cx - (base[0] + base[2] / 2)) < 1, // not pinned to a corner
|
||||
centredY: Math.abs(cy - (base[1] + base[3] / 2)) < 1,
|
||||
};
|
||||
});
|
||||
out.zoomOutWorks = out.zoomOut.zoom === 0.5 && out.zoomOut.wider
|
||||
&& out.zoomOut.centredX && out.zoomOut.centredY;
|
||||
delete out.zoomOut;
|
||||
// docs/CANVAS.md §5: the zoom-out floor moved from a fixed 0.4 of the old
|
||||
// view_box to 1/3 of the CONTENT frame — three canvases of empty plane is as
|
||||
// far as it is useful to go when the canvas has no edges.
|
||||
out.floorIsThird = Math.abs(await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
|
||||
c._applyView(0.01); return c._zoom; }) - 1 / 3) < 1e-9; // clamped at the floor
|
||||
await page.evaluate(() => window.__card._resetZoom());
|
||||
|
||||
// -- editor zoom is a working tool, not the viewing intent ----------------
|
||||
// view 1.6 → devices editor 2.5 → back to view: the pre-editor viewport
|
||||
// (zoom AND center) comes back; the editor keeps its own zoom while open.
|
||||
out.editorZoomNotSaved = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const raf2 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const center = () => { const v = c._view; return [v.x + v.w / 2, v.y + v.h / 2]; };
|
||||
c._setMode('view');
|
||||
c._resetZoom();
|
||||
c._zoomAt(10, 10, 1.6); c._saveZoom(); // off-center on purpose
|
||||
const want = { zoom: c._zoom, c: center() };
|
||||
c._setMode('devices'); await raf2();
|
||||
c._zoomAt(10, 10, 2.5); c._saveZoom(); // off-center too: the center must not leak either
|
||||
const editorZoomFree = Math.abs(c._zoom - 2.5) < 0.01; // zooming inside stays
|
||||
c._setMode('view'); await raf2();
|
||||
const got = { zoom: c._zoom, c: center() };
|
||||
const ls = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}');
|
||||
return {
|
||||
editorZoomFree,
|
||||
zoomRestored: Math.abs(got.zoom - want.zoom) < 0.01,
|
||||
centerRestored: Math.hypot(got.c[0] - want.c[0], got.c[1] - want.c[1]) < 0.02,
|
||||
lsRestored: Math.abs((ls[c._space] || 1) - want.zoom) < 0.01,
|
||||
};
|
||||
});
|
||||
out.editorZoomFree = out.editorZoomNotSaved.editorZoomFree;
|
||||
out.viewZoomRestored = out.editorZoomNotSaved.zoomRestored;
|
||||
out.viewCenterRestored = out.editorZoomNotSaved.centerRestored;
|
||||
out.viewZoomBackInLs = out.editorZoomNotSaved.lsRestored;
|
||||
delete out.editorZoomNotSaved;
|
||||
|
||||
// -- editor entered and left without touching zoom: no jump ---------------
|
||||
out.untouchedEditorNoJump = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const raf2 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
c._setMode('view');
|
||||
c._resetZoom();
|
||||
c._zoomAt(30, 30, 1.6); c._saveZoom();
|
||||
const before = { ...c._view };
|
||||
c._setMode('plan'); await raf2();
|
||||
c._setMode('view'); await raf2();
|
||||
const v = c._view;
|
||||
return Math.abs(c._zoom - 1.6) < 0.01
|
||||
&& Math.abs(v.x - before.x) < 0.005 && Math.abs(v.y - before.y) < 0.005
|
||||
&& Math.abs(v.w - before.w) < 0.005 && Math.abs(v.h - before.h) < 0.005;
|
||||
});
|
||||
|
||||
// -- the view zoom still survives a space switch, per space ---------------
|
||||
out.viewZoomSurvivesSpaceSwitch = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
c._setMode('view');
|
||||
c._resetZoom(); c._applyView(1.7); c._saveZoom();
|
||||
c._slideTo('garden', 'left'); await wait(350);
|
||||
const gardenGotOwnZoom = Math.abs(c._zoom - (JSON.parse(
|
||||
localStorage.getItem('houseplan_card_zoom_v1') || '{}').garden || 1)) < 0.01;
|
||||
c._slideTo('f1', 'right'); await wait(350);
|
||||
return gardenGotOwnZoom && Math.abs(c._zoom - 1.7) < 0.01;
|
||||
});
|
||||
await page.evaluate(() => window.__card._resetZoom());
|
||||
|
||||
// -- owner's dacha regression: editor 500% must never reach the per-space
|
||||
// view store. View zoom set on BOTH floors, editor cranked to 5.0, back to
|
||||
// view — and the floor tab is clicked in the SAME tick (on the tablet the
|
||||
// exit re-render janks, so the click runs before the restore rAF and the
|
||||
// old fix-up save was skipped). Two switches later the stale 5.0 came back.
|
||||
out.editorZoomNeverInSpaceStore = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const raf2 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
c._setMode('view');
|
||||
c._resetZoom(); c._zoomAt(10, 10, 1.6); c._saveZoom(); // floor 1 view zoom
|
||||
c._slideTo('garden', 'left'); await wait(350);
|
||||
c._resetZoom(); c._zoomAt(10, 10, 1.4); c._saveZoom(); // floor 2 view zoom
|
||||
c._setMode('devices'); await raf2();
|
||||
c._zoomAt(10, 10, 5.0); c._saveZoom(); // 500% — a working tool
|
||||
const lsInEditor = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}');
|
||||
c._setMode('view');
|
||||
c._slideTo('f1', 'right'); await wait(350); // same tick as the exit — beats the rAF
|
||||
const firstSwitch = Math.abs(c._zoom - 1.6) < 0.01;
|
||||
c._slideTo('garden', 'left'); await wait(350);
|
||||
const secondSwitch = Math.abs(c._zoom - 1.4) < 0.01; // used to come back as 5.0
|
||||
const ls = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}');
|
||||
c._slideTo('f1', 'right'); await wait(350); c._resetZoom(); // leave the stage as the next tests expect
|
||||
return {
|
||||
editorNotPersisted: Math.abs((lsInEditor.garden || 1) - 1.4) < 0.01,
|
||||
firstSwitch,
|
||||
secondSwitch,
|
||||
lsClean: Math.abs((ls.garden || 1) - 1.4) < 0.01 && Math.abs((ls.f1 || 1) - 1.6) < 0.01,
|
||||
};
|
||||
});
|
||||
out.editorZoomStaysOutOfLs = out.editorZoomNeverInSpaceStore.editorNotPersisted;
|
||||
out.ownerFirstSwitchOk = out.editorZoomNeverInSpaceStore.firstSwitch;
|
||||
out.ownerSecondSwitchOk = out.editorZoomNeverInSpaceStore.secondSwitch;
|
||||
out.ownerLsClean = out.editorZoomNeverInSpaceStore.lsClean;
|
||||
delete out.editorZoomNeverInSpaceStore;
|
||||
|
||||
// -- HP-1543-01: смена этажа ВНУТРИ редактора не тащит editor zoom в view --
|
||||
// view-зум сохранён на обоих этажах → редактор на f1 → переключение ВКЛАДКОЙ
|
||||
// на garden → editor zoom 5.0 → выход в view (остаёмся на garden): вид обязан
|
||||
// вернуться к сохранённому view-зуму garden (1.4) с штатным центром
|
||||
// _restoreZoom(), а не остаться на редакторских 500% (снапшот-guard по space
|
||||
// отбрасывал восстановление, а текущий _zoom так и оставался редакторским).
|
||||
out.crossFloorEditorExit = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const raf2 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
c._setMode('view');
|
||||
c._resetZoom(); c._zoomAt(10, 10, 1.6); c._saveZoom(); // f1 view zoom
|
||||
c._slideTo('garden', 'left'); await wait(350);
|
||||
c._resetZoom(); c._zoomAt(10, 10, 1.4); c._saveZoom(); // garden view zoom
|
||||
c._slideTo('f1', 'right'); await wait(350);
|
||||
c._setMode('devices'); await raf2();
|
||||
// этаж меняется НАСТОЯЩЕЙ вкладкой — именно этот путь под подозрением
|
||||
const idx = c._model.findIndex((s) => s.id === 'garden');
|
||||
sr.querySelectorAll('.tabs .tab')[idx].click();
|
||||
await c.updateComplete; await raf2();
|
||||
c._zoomAt(10, 10, 5.0); c._saveZoom(); // 500% — рабочий инструмент
|
||||
c._setMode('view'); await raf2(); await wait(60);
|
||||
const stillGarden = c._space === 'garden';
|
||||
const gotZoom = c._zoom;
|
||||
const v = c._view; const vb = c._baseVb();
|
||||
const centred = !!v && Math.abs(v.x + v.w / 2 - (vb[0] + vb[2] / 2)) < 1
|
||||
&& Math.abs(v.y + v.h / 2 - (vb[1] + vb[3] / 2)) < 1;
|
||||
const ls = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}');
|
||||
const store = { ...c._zoomBySpace };
|
||||
c._slideTo('f1', 'right'); await wait(350); c._resetZoom(); // вернуть сцену следующим тестам
|
||||
return {
|
||||
stillGarden,
|
||||
viewZoomRestoredCrossFloor: Math.abs(gotZoom - 1.4) < 0.01, // было 5.0 (HP-1543-01)
|
||||
centerRestoredCrossFloor: centred, // центр как у _restoreZoom()
|
||||
storeIntactCrossFloor: Math.abs((store.garden || 1) - 1.4) < 0.01, // _zoomBySpace не тронут
|
||||
lsIntactCrossFloor: Math.abs((ls.garden || 1) - 1.4) < 0.01
|
||||
&& Math.abs((ls.f1 || 1) - 1.6) < 0.01, // LS_ZOOM не тронут
|
||||
};
|
||||
});
|
||||
out.crossFloorStillGarden = out.crossFloorEditorExit.stillGarden;
|
||||
out.crossFloorViewZoomRestored = out.crossFloorEditorExit.viewZoomRestoredCrossFloor;
|
||||
out.crossFloorCenterRestored = out.crossFloorEditorExit.centerRestoredCrossFloor;
|
||||
out.crossFloorStoreIntact = out.crossFloorEditorExit.storeIntactCrossFloor;
|
||||
out.crossFloorLsIntact = out.crossFloorEditorExit.lsIntactCrossFloor;
|
||||
delete out.crossFloorEditorExit;
|
||||
|
||||
// -- devices outside rooms stretch the default frame ---------------------
|
||||
out.devicesStretchFrame = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
|
||||
c._serverCfg = cfg; c._model = null;
|
||||
const before = c._baseVb();
|
||||
// walk one lamp far outside every room
|
||||
c._layout = { ...c._layout, d_lamp: { s: 'f1', x: 0.99, y: 0.5 } };
|
||||
const after = c._baseVb();
|
||||
return after[0] + after[2] > before[0] + before[2] + 20; // right edge follows the lamp
|
||||
});
|
||||
|
||||
// -- a card BELOW other dashboard content still gets a stage (HP-1500-02) --
|
||||
out.stageSurvivesContentAbove = await page.evaluate(async () => {
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.height = '900px';
|
||||
document.body.insertBefore(spacer, document.body.firstChild);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const h = sr.querySelector('.stage').getBoundingClientRect().height;
|
||||
spacer.remove();
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
// the old code billed the 900px spacer as "header" and left a 0px stage
|
||||
return h > 300;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
+1416
-515
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="200" viewBox="0 0 800 200"><rect width="800" height="200" fill="#eee"/></svg>
|
||||
|
After Width: | Height: | Size: 137 B |
+7
-7
@@ -51,17 +51,17 @@ customElements.define('ha-card',HaCard);
|
||||
<script type="module">
|
||||
const CFG = {
|
||||
spaces: [
|
||||
{ id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', aspect:1.25,
|
||||
{ id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', plan_aspect:1.25,
|
||||
view_box:[0,0,1,1],
|
||||
rooms:[
|
||||
{id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.05],[0.55,0.05],[0.55,0.6],[0.04,0.6]]},
|
||||
{id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.05],[0.96,0.05],[0.96,0.45],[0.55,0.45]]},
|
||||
{id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.45],[0.96,0.45],[0.96,0.95],[0.55,0.95]]},
|
||||
{id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.6],[0.55,0.6],[0.55,0.95],[0.04,0.95]]}
|
||||
{id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]]},
|
||||
{id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.14],[0.96,0.14],[0.96,0.46],[0.55,0.46]]},
|
||||
{id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.46],[0.96,0.46],[0.96,0.86],[0.55,0.86]]},
|
||||
{id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.58],[0.55,0.58],[0.55,0.86],[0.04,0.86]]}
|
||||
], segments:[] },
|
||||
{ id:'garden', title:'Garden', plan_url:'/assets/garden.svg', aspect:1.4286,
|
||||
{ id:'garden', title:'Garden', plan_url:'/assets/garden.svg', plan_aspect:1.4286,
|
||||
view_box:[0,0,1,1],
|
||||
rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.04],[0.97,0.04],[0.97,0.96],[0.03,0.96]]} ], segments:[] }
|
||||
rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.178],[0.97,0.178],[0.97,0.822],[0.03,0.822]]} ], segments:[] }
|
||||
],
|
||||
markers: [],
|
||||
settings: {}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Demo-stand extras
|
||||
|
||||
Things that live on the public stand (demo.houseplan.tech) but are not part
|
||||
of the shipped integration.
|
||||
|
||||
- `demo_robot/` — the scripted robot vacuum (docs/VACUUM.md, "demo stand gets
|
||||
a scripted synthetic robot"). Deployed by copying the folder to
|
||||
`custom_components/demo_robot` in both stand seeds and adding `demo_robot:`
|
||||
to configuration.yaml; the dev stand picks it up automatically from this
|
||||
path (`/opt/hp/bin/hp-update-dev.sh`). The rest of the stand-only config
|
||||
(template LQI sensors, alarm helpers, the smoke automation) lives in the
|
||||
seeds on the stand host — see `docs/TESTING-DEMO.md` and the memory note
|
||||
`houseplan-demo-stand`.
|
||||
|
||||
- `demo_guard/` — stand-only guard (2026-07-31). Visitors log in as an
|
||||
administrator (the card editor is gated on `is_admin`) and kept restarting
|
||||
HA from the UI, which looked like the stand crashing between hourly resets.
|
||||
The component re-registers `homeassistant.restart`/`homeassistant.stop` as
|
||||
no-ops after startup. Deployed to `custom_components/demo_guard` in
|
||||
seed-demo only (the dev stand sits behind basic auth) + `demo_guard:` in
|
||||
configuration.yaml.
|
||||
|
||||
- `www/stand-reset-timer.js` — console-only countdown to the next hourly
|
||||
reset (`hp-reset.timer`, every hour at :00). Served as
|
||||
`/local/stand-reset-timer.js` from `seed-demo/www/`, wired through
|
||||
`frontend: extra_module_url`. Logs a styled `console.info` every minute,
|
||||
switching to `console.warn` for the last 5 minutes. `www/stand-dev-info.js`
|
||||
is the dev-stand counterpart: a single `console.info` saying the dev stand
|
||||
only resets on deploy.
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Stand-only guard: keep visitor-admins from killing the public demo.
|
||||
|
||||
Visitors log in as `demo`, and that user HAS to be an administrator — the
|
||||
House Plan card gates its editor on `user.is_admin`, and demonstrating the
|
||||
editor is the whole point of the stand. But an administrator can also call
|
||||
`homeassistant.restart` / `homeassistant.stop`, and visitors do exactly that
|
||||
(seen live 2026-07-31: HA exited with the restart code 100 minutes after a
|
||||
scheduled reset, right as a visitor session from an external IP was active —
|
||||
that is what looked like «стенд перезагружается чаще, чем раз в час»).
|
||||
|
||||
Once startup finishes this component re-registers both services as no-ops:
|
||||
the UI buttons stay, pressing them only leaves a WARNING in the log. The
|
||||
hourly stand reset is NOT affected — hp-reset.timer restarts the docker
|
||||
container from the outside.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "demo_guard"
|
||||
BLOCKED = ("restart", "stop")
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def _blocked(call: ServiceCall) -> None:
|
||||
_LOGGER.warning(
|
||||
"демо-стенд: homeassistant.%s отключён для посетителей "
|
||||
"(стенд и так сбрасывается сам, ежечасно в :00)",
|
||||
call.service,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _neuter(_event) -> None:
|
||||
# After EVENT_HOMEASSISTANT_STARTED every core service is in place;
|
||||
# registering the same domain/name again replaces the handler.
|
||||
for service in BLOCKED:
|
||||
hass.services.async_register("homeassistant", service, _blocked)
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _neuter)
|
||||
return True
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"domain": "demo_guard",
|
||||
"name": "Demo Stand Guard",
|
||||
"codeowners": [],
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/Matysh/houseplan-card/tree/dev/demo/stand/demo_guard",
|
||||
"iot_class": "calculated",
|
||||
"requirements": [],
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# demo_guard re-registers EXISTING homeassistant.restart / homeassistant.stop
|
||||
# as no-ops on the public demo stand (visitors are admins by necessity — the
|
||||
# card's editor requires is_admin — and kept restarting HA). It defines no
|
||||
# services of its own; this file exists because hassfest requires it for any
|
||||
# integration that calls async_register.
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Scripted robot vacuum for the public demo stand.
|
||||
|
||||
The real feature (docs/VACUUM.md) targets Tier-A map integrations — Xiaomi
|
||||
Cloud Map Extractor, Tasshack dreame-vacuum, Valetudo. None of them can run
|
||||
on the stand (no hardware), so the stand ships this simulator instead: a
|
||||
`vacuum` entity with start/pause/return_to_base and a `sensor` whose
|
||||
attributes follow the Tasshack shape (`vacuum_position` as a dict, `rooms`
|
||||
with names matching the plan rooms, `map_index`). Room names match the demo
|
||||
house on purpose — that is what makes «Настроить автоматически» demonstrable.
|
||||
|
||||
Coordinates are millimetres in 0..8000 with the Y axis flipped versus the
|
||||
screen (like every real robot map seen so far) so the mirror toggle in the
|
||||
fit panel has meaning on the stand too.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import timedelta
|
||||
from typing import Any, Callable
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
DOMAIN = "demo_robot"
|
||||
TICK_S = 2 # telemetry cadence
|
||||
SPEED_MM = 600 # per tick ≈ 0.3 m/s, a realistic robot pace
|
||||
DOCK = (3900.0, 2600.0)
|
||||
|
||||
# Robot-map rooms, mm, Y up (flipped vs screen). Bboxes mirror the plan's
|
||||
# room polygons; the names MUST equal the plan room names — auto-calibration
|
||||
# matches by name.
|
||||
ROOMS: dict[str, tuple[str, float, float, float, float]] = {
|
||||
"1": ("Гостиная", 480, 4320, 4160, 7360),
|
||||
"2": ("Кухня", 4160, 5120, 7520, 7360),
|
||||
"3": ("Спальня", 4160, 2400, 7520, 5120),
|
||||
"4": ("Кабинет", 480, 2400, 2720, 4320),
|
||||
"5": ("Прихожая", 2720, 2400, 4160, 4320),
|
||||
}
|
||||
# cleaning order: start at the dock in the hallway, end next to it
|
||||
CLEAN_ORDER = ("5", "4", "1", "2", "3")
|
||||
INSET = 350.0 # keep lanes away from walls
|
||||
LANE_MM = 650.0 # serpentine lane spacing
|
||||
|
||||
|
||||
def _serpentine(x0: float, y0: float, x1: float, y1: float) -> list[tuple[float, float]]:
|
||||
"""Boustrophedon lanes across a room bbox, like a real vacuum drives."""
|
||||
x0, y0, x1, y1 = x0 + INSET, y0 + INSET, x1 - INSET, y1 - INSET
|
||||
pts: list[tuple[float, float]] = []
|
||||
y = y0
|
||||
left_to_right = True
|
||||
while y <= y1 + 1:
|
||||
xs = (x0, x1) if left_to_right else (x1, x0)
|
||||
pts.append((xs[0], y))
|
||||
pts.append((xs[1], y))
|
||||
left_to_right = not left_to_right
|
||||
y += LANE_MM
|
||||
return pts
|
||||
|
||||
|
||||
def build_route() -> list[tuple[float, float]]:
|
||||
route: list[tuple[float, float]] = [DOCK]
|
||||
for rid in CLEAN_ORDER:
|
||||
_, x0, y0, x1, y1 = ROOMS[rid]
|
||||
route.extend(_serpentine(x0, y0, x1, y1))
|
||||
route.append(DOCK)
|
||||
return route
|
||||
|
||||
|
||||
class RobotSim:
|
||||
"""One shared simulation both entities read from."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
self.hass = hass
|
||||
self.route = build_route()
|
||||
self.state = "docked" # docked|cleaning|paused|returning
|
||||
self.pos: tuple[float, float] = DOCK
|
||||
self.heading = 0.0
|
||||
self._seg = 0 # index of the segment being driven
|
||||
self._seg_done = 0.0 # mm driven inside the current segment
|
||||
self._back: list[tuple[float, float]] | None = None # manual return path
|
||||
self._listeners: list[Callable[[], None]] = []
|
||||
|
||||
# ---- entity plumbing ----
|
||||
def add_listener(self, cb: Callable[[], None]) -> Callable[[], None]:
|
||||
self._listeners.append(cb)
|
||||
|
||||
def _remove() -> None:
|
||||
if cb in self._listeners:
|
||||
self._listeners.remove(cb)
|
||||
return _remove
|
||||
|
||||
def _notify(self) -> None:
|
||||
for cb in list(self._listeners):
|
||||
cb()
|
||||
|
||||
# ---- commands (called by the vacuum entity) ----
|
||||
def start(self) -> None:
|
||||
if self.state == "paused":
|
||||
self.state = "returning" if self._back else "cleaning"
|
||||
else:
|
||||
self.state = "cleaning"
|
||||
self._seg = 0
|
||||
self._seg_done = 0.0
|
||||
self._back = None
|
||||
self.pos = DOCK
|
||||
self._notify()
|
||||
|
||||
def pause(self) -> None:
|
||||
if self.state in ("cleaning", "returning"):
|
||||
self.state = "paused"
|
||||
self._notify()
|
||||
|
||||
def stop(self) -> None:
|
||||
self.pause()
|
||||
|
||||
def return_to_base(self) -> None:
|
||||
if self.state == "docked":
|
||||
return
|
||||
# retrace the visited waypoints backwards — a straight line to the
|
||||
# dock would cut through walls
|
||||
visited = self.route[: self._seg + 1]
|
||||
self._back = [self.pos] + list(reversed(visited))
|
||||
self._seg = 0
|
||||
self._seg_done = 0.0
|
||||
self.state = "returning"
|
||||
self._notify()
|
||||
|
||||
# ---- physics ----
|
||||
def _polyline(self) -> list[tuple[float, float]]:
|
||||
return self._back if self._back is not None else self.route
|
||||
|
||||
def tick(self, _now: Any = None) -> None:
|
||||
if self.state not in ("cleaning", "returning"):
|
||||
return
|
||||
line = self._polyline()
|
||||
# manual return drives twice as fast, like the real thing hurrying home
|
||||
budget = SPEED_MM * (2 if self._back is not None else 1)
|
||||
while budget > 0 and self._seg < len(line) - 1:
|
||||
ax, ay = line[self._seg]
|
||||
bx, by = line[self._seg + 1]
|
||||
seg_len = math.hypot(bx - ax, by - ay)
|
||||
left = seg_len - self._seg_done
|
||||
if left <= budget:
|
||||
budget -= left
|
||||
self._seg += 1
|
||||
self._seg_done = 0.0
|
||||
continue
|
||||
self._seg_done += budget
|
||||
t = self._seg_done / seg_len
|
||||
self.pos = (ax + (bx - ax) * t, ay + (by - ay) * t)
|
||||
self.heading = math.degrees(math.atan2(by - ay, bx - ax)) % 360
|
||||
budget = 0
|
||||
if self._seg >= len(line) - 1:
|
||||
# arrived: either the route is finished or the manual return is
|
||||
self.pos = line[-1]
|
||||
self.state = "docked"
|
||||
self._back = None
|
||||
elif self._back is None and self._seg >= len(line) - 2:
|
||||
# the last route leg is the drive home
|
||||
self.state = "returning"
|
||||
self._notify()
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
sim = RobotSim(hass)
|
||||
hass.data[DOMAIN] = sim
|
||||
|
||||
@callback
|
||||
def _tick(now: Any) -> None:
|
||||
sim.tick(now)
|
||||
|
||||
async_track_time_interval(hass, _tick, timedelta(seconds=TICK_S))
|
||||
hass.async_create_task(
|
||||
discovery.async_load_platform(hass, "vacuum", DOMAIN, {}, config)
|
||||
)
|
||||
hass.async_create_task(
|
||||
discovery.async_load_platform(hass, "sensor", DOMAIN, {}, config)
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"domain": "demo_robot",
|
||||
"name": "Demo Robot Vacuum",
|
||||
"codeowners": [],
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/Matysh/houseplan-card/tree/dev/demo/stand/demo_robot",
|
||||
"iot_class": "calculated",
|
||||
"requirements": [],
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"""The map sensor of the stand simulator — Tasshack-shaped attributes.
|
||||
|
||||
`vacuum_position` is a plain dict on purpose: the real Tasshack integration
|
||||
keeps a Point OBJECT in memory (houseplan's trail recorder handles both), but
|
||||
a dict is what any template/attribute tooling on the stand can also read.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import DOMAIN, ROOMS, RobotSim
|
||||
|
||||
|
||||
async def async_setup_platform(hass: HomeAssistant, config, add_entities, discovery_info=None) -> None:
|
||||
add_entities([DemoRobotMap(hass.data[DOMAIN])])
|
||||
|
||||
|
||||
class DemoRobotMap(SensorEntity):
|
||||
_attr_should_poll = False
|
||||
_attr_name = "Карта робота-пылесоса"
|
||||
_attr_unique_id = "demo_robot_map"
|
||||
_attr_icon = "mdi:map"
|
||||
|
||||
def __init__(self, sim: RobotSim) -> None:
|
||||
self._sim = sim
|
||||
self.entity_id = "sensor.demo_robot_map"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
self.async_on_remove(self._sim.add_listener(self.async_write_ha_state))
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
return self._sim.state
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
x, y = self._sim.pos
|
||||
return {
|
||||
"vacuum_position": {
|
||||
"x": round(x, 1),
|
||||
"y": round(y, 1),
|
||||
"a": round(self._sim.heading, 1),
|
||||
},
|
||||
"rooms": {
|
||||
rid: {
|
||||
"name": name,
|
||||
"x0": x0, "y0": y0, "x1": x1, "y1": y1,
|
||||
"x": (x0 + x1) / 2, "y": (y0 + y1) / 2,
|
||||
}
|
||||
for rid, (name, x0, y0, x1, y1) in ROOMS.items()
|
||||
},
|
||||
"map_index": 1,
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"""The vacuum entity of the stand simulator."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.vacuum import (
|
||||
StateVacuumEntity,
|
||||
VacuumActivity,
|
||||
VacuumEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import DOMAIN, RobotSim
|
||||
|
||||
ACTIVITY = {
|
||||
"docked": VacuumActivity.DOCKED,
|
||||
"cleaning": VacuumActivity.CLEANING,
|
||||
"paused": VacuumActivity.PAUSED,
|
||||
"returning": VacuumActivity.RETURNING,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_platform(hass: HomeAssistant, config, add_entities, discovery_info=None) -> None:
|
||||
add_entities([DemoRobotVacuum(hass.data[DOMAIN])])
|
||||
|
||||
|
||||
class DemoRobotVacuum(StateVacuumEntity):
|
||||
_attr_should_poll = False
|
||||
_attr_name = "Робот-пылесос"
|
||||
_attr_unique_id = "demo_robot_vacuum"
|
||||
_attr_supported_features = (
|
||||
VacuumEntityFeature.START
|
||||
| VacuumEntityFeature.STOP
|
||||
| VacuumEntityFeature.PAUSE
|
||||
| VacuumEntityFeature.RETURN_HOME
|
||||
| VacuumEntityFeature.STATE
|
||||
)
|
||||
|
||||
def __init__(self, sim: RobotSim) -> None:
|
||||
self._sim = sim
|
||||
self.entity_id = "vacuum.demo_robot"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
self.async_on_remove(self._sim.add_listener(self.async_write_ha_state))
|
||||
|
||||
@property
|
||||
def activity(self) -> VacuumActivity:
|
||||
return ACTIVITY.get(self._sim.state, VacuumActivity.IDLE)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
# the card resolves the active map name through the vacuum entity
|
||||
return {"selected_map": "1"}
|
||||
|
||||
async def async_start(self) -> None:
|
||||
self._sim.start()
|
||||
|
||||
async def async_pause(self) -> None:
|
||||
self._sim.pause()
|
||||
|
||||
async def async_stop(self, **kwargs: Any) -> None:
|
||||
self._sim.stop()
|
||||
|
||||
async def async_return_to_base(self, **kwargs: Any) -> None:
|
||||
self._sim.return_to_base()
|
||||
@@ -0,0 +1,7 @@
|
||||
// Dev-стенд House Plan: ежечасного сброса НЕТ. Контейнер перезапускается
|
||||
// только при новом коммите в ветку dev (hp-update-dev.timer, проверка раз
|
||||
// в 10 минут). Одно информационное сообщение при загрузке, без таймера.
|
||||
console.info(
|
||||
"%c[dev-стенд] сброс только при деплое: перезапуск при новом коммите в dev (проверка раз в 10 мин)",
|
||||
"background:#5d4037;color:#fff;padding:2px 6px;border-radius:3px"
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
// Демо-стенд House Plan: таймер до ближайшего сброса — пишет ТОЛЬКО в консоль
|
||||
// DevTools, никакого UI. Стенд сбрасывается systemd-таймером hp-reset.timer
|
||||
// ежечасно в :00 (RandomizedDelaySec=0; сам перезапуск занимает ~15 секунд,
|
||||
// отсюда «~» в сообщении).
|
||||
(() => {
|
||||
"use strict";
|
||||
const INFO = "background:#03589c;color:#fff;padding:2px 6px;border-radius:3px";
|
||||
const WARN = "background:#b71c1c;color:#fff;padding:2px 6px;border-radius:3px;font-weight:bold";
|
||||
|
||||
const tick = () => {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setMinutes(60, 0, 0); // ближайший :00
|
||||
const left = Math.max(1, Math.ceil((next - now) / 60000));
|
||||
if (left <= 5) {
|
||||
console.warn(
|
||||
`%c[демо-стенд] до сброса ~${left} мин — несохранённые эксперименты пропадут (сброс ежечасно в :00)`,
|
||||
WARN
|
||||
);
|
||||
} else {
|
||||
console.info(`%c[демо-стенд] до сброса ~${left} мин (сброс ежечасно в :00)`, INFO);
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
// выравниваемся по границе минуты, дальше — раз в минуту
|
||||
setTimeout(() => {
|
||||
tick();
|
||||
setInterval(tick, 60000);
|
||||
}, (60 - new Date().getSeconds()) * 1000);
|
||||
})();
|
||||
Vendored
+1416
-515
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user