mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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,12 +17,19 @@ 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**
|
||||
|
||||
- 🖱 **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).
|
||||
@@ -33,6 +41,11 @@ right on your Lovelace dashboard.
|
||||
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.
|
||||
|
||||
|
||||
+14
-1
@@ -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,12 +15,19 @@
|
||||
|
||||

|
||||
|
||||
> ### 🚀 Попробовать вживую — без установки
|
||||
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home
|
||||
> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё:
|
||||
> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам
|
||||
> возвращается в исходное состояние.
|
||||
|
||||
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
|
||||
**Главное**
|
||||
|
||||
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
|
||||
виртуальные стены и декор-слой рисуются кликами; помощник выравнивания и
|
||||
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
|
||||
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
|
||||
линейка в реальных метрах.
|
||||
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
|
||||
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
|
||||
@@ -31,6 +39,11 @@
|
||||
никогда случайным тапом.
|
||||
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
|
||||
этажами, автокарусель, свои размеры на каждом экране.
|
||||
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
|
||||
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
|
||||
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
|
||||
(по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi
|
||||
Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo.
|
||||
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
|
||||
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
|
||||
|
||||
|
||||
@@ -50,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))
|
||||
@@ -194,6 +203,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
|
||||
|
||||
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 —
|
||||
|
||||
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.52.2"
|
||||
VERSION = "1.55.2"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.52.2"
|
||||
"version": "1.55.2"
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -173,6 +173,8 @@ 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),
|
||||
@@ -269,7 +271,31 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("model"): _TEXT_OR_NONE,
|
||||
vol.Optional("link"): vol.Any(None, _URL),
|
||||
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", None),
|
||||
# the 'run' target: only the runnable domains, nothing else is callable
|
||||
vol.Optional("tap_target"): vol.Any(
|
||||
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
|
||||
),
|
||||
vol.Optional("tap_confirm"): vol.Any(bool, None),
|
||||
# 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),
|
||||
@@ -295,6 +321,8 @@ 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)),
|
||||
# background around the plan, all spaces (a space may override)
|
||||
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
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(
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
|
||||
MAX_SIGN_PATHS,
|
||||
@@ -40,6 +41,7 @@ _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)
|
||||
@@ -692,6 +694,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
except Exception: # noqa: BLE001 — see above: the commit stands regardless
|
||||
_LOGGER.exception("House Plan: collecting superseded files failed")
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
# refresh repair issues (broken plan references) without waiting for a restart
|
||||
entry = get_entry(hass)
|
||||
if entry is not None:
|
||||
@@ -767,3 +770,18 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
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,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,144 @@
|
||||
// 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);
|
||||
await finish(browser, res);
|
||||
@@ -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();
|
||||
|
||||
@@ -28,8 +28,8 @@ const res = await page.evaluate(async () => {
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"rows": 11,
|
||||
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill"],
|
||||
"rows": 12, // 10 цветов + радиус свечения + фон вокруг плана
|
||||
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background"],
|
||||
"saved": {"c": "#ff00ff", "a": 0.5},
|
||||
"lqiBefore": 7,
|
||||
"lqiAfter": 0,
|
||||
|
||||
@@ -35,4 +35,60 @@ const out = await page.evaluate(async () => {
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
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,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,144 @@
|
||||
// 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 = {};
|
||||
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 () => {
|
||||
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,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 });
|
||||
@@ -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 });
|
||||
@@ -56,6 +56,8 @@ checkAll(res, {
|
||||
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
|
||||
"lqiFills": 0,
|
||||
"atticSquare": true,
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "none", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
// 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": "#3ea6ff", "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);
|
||||
|
||||
@@ -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));
|
||||
@@ -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,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);
|
||||
@@ -48,6 +48,149 @@ out.floorIsHalf = await page.evaluate(() => { window.__card._resetZoom(); const
|
||||
c._applyView(0.1); return c._zoom; }) === 0.4; // 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;
|
||||
|
||||
+561
-153
File diff suppressed because one or more lines are too long
@@ -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
+561
-153
File diff suppressed because one or more lines are too long
@@ -17,6 +17,7 @@ houseplan-card/
|
||||
├─ dist/houseplan-card.js # build (rollup+terser), ~290 KB, plans embedded
|
||||
├─ custom_components/houseplan/ # the HA integration
|
||||
│ ├─ __init__.py # setup: Store, WS commands, JS serving (add_extra_js_url)
|
||||
│ ├─ trails.py # server-side vacuum trail recorder (state-change driven)
|
||||
│ ├─ websocket_api.py # houseplan/layout/get|set|update
|
||||
│ ├─ config_flow.py # single entry; admin_only option (editing restricted to admins)
|
||||
│ ├─ const.py # DOMAIN, STORAGE_KEY, VERSION, FRONTEND_URL
|
||||
@@ -189,6 +190,7 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
|
||||
| `houseplan/layout/set` | `layout`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` |
|
||||
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
|
||||
| `houseplan/config/get` | — | `{config, rev}` |
|
||||
| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs, raw robot coords |
|
||||
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
|
||||
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
|
||||
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) |
|
||||
|
||||
@@ -1,5 +1,262 @@
|
||||
# Changelog
|
||||
|
||||
## v1.55.2 — 2026-08-02
|
||||
|
||||
Patch release: a calmer first open — no zoom flash and no layout jumps
|
||||
while Home Assistant is still settling — plus a new background color
|
||||
setting and two bits of editor polish.
|
||||
|
||||
- **Opening a plan no longer flashes the default view before your saved
|
||||
zoom** (HP-1551). The saved zoom used to be applied a beat after the
|
||||
first render, so the card painted the default fit for a frame and then
|
||||
snapped to your position. It is now applied synchronously, before the
|
||||
first paint — the plan appears exactly where you left it.
|
||||
- **A first-open veil hides the plan until the layout settles**
|
||||
(HP-1552). While Home Assistant is still loading its side panels, the
|
||||
stage height can change several times, and the plan visibly jumped
|
||||
along with it. The card now waits under a dark veil with a pulsing
|
||||
house outline until the height stops moving, then reveals the plan
|
||||
already in place. Kiosk mode skips the veil entirely.
|
||||
- **New setting: background color around the plan** (HP-1554). The area
|
||||
around the plan can now be any color — set it in general settings for
|
||||
the whole card, or override it per space. The backend validates the
|
||||
value as a #rrggbb hex color.
|
||||
- **Round caps and joins on background-editor lines** (HP-1553). Strokes
|
||||
used to end and meet in square cuts; line ends and corners are rounded
|
||||
now, so traced walls and outlines look clean.
|
||||
- **The Resize tool's wall handles now look the part.** They are half
|
||||
the previous size, show a wall-with-arrows glyph that rotates to
|
||||
follow the wall, and use the grab cursor — while the hit area stays
|
||||
exactly as large as before.
|
||||
|
||||
## v1.55.1 — 2026-08-01
|
||||
|
||||
Patch release: the four findings of the v1.55.0 audit, all in the new
|
||||
Room resize tool (HP-1550-01..04).
|
||||
|
||||
- **A pending save can no longer leak a live drag to the server**
|
||||
(HP-1550-01). The resize preview used to be written straight into the
|
||||
shared config object, and a debounced save still queued from a
|
||||
previous edit could snapshot it mid-drag — so a resize you then
|
||||
cancelled with Esc quietly survived on the server and came back after
|
||||
a reload. The live geometry now stays in a separate overlay that only
|
||||
the renderer sees; it reaches the config exactly once, when the
|
||||
handle is released.
|
||||
- **An interrupted drag is cancelled, not committed** (HP-1550-03).
|
||||
When the system cuts a drag short — switching apps, palm rejection on
|
||||
a tablet — the tool used to treat it as a release and save the
|
||||
half-finished geometry. Such interruptions now take the same path as
|
||||
Esc: the original geometry comes back, no undo step, no save.
|
||||
- **A door in the middle of a wall no longer blocks resizing it**
|
||||
(HP-1550-04). The door's invisible hit area used to sit on top of the
|
||||
wall handle, making such a wall ungrabbable for both rooms. In the
|
||||
resize tool the handles now own the hit test (openings are not
|
||||
editable there anyway — they simply travel with the wall), while all
|
||||
other editor tools keep openings clickable exactly as before.
|
||||
- **The ~30 cm minimum size now holds for any room shape**
|
||||
(HP-1550-02). The old check only measured parallel opposite walls, so
|
||||
a triangular room could be squeezed into a sliver, and a rotated
|
||||
room could scale its real short side below the floor unnoticed. Both
|
||||
measures are orientation-independent now: wall drags respect every
|
||||
obstacle in the wall's path (a triangle's apex, a slanted wall), and
|
||||
the scale frame uses the room's true minimum width. Rooms already
|
||||
thinner than the floor may still be improved, never made worse.
|
||||
|
||||
## v1.55.0 — 2026-08-01
|
||||
|
||||
Minor release: rooms are no longer set in stone — a dedicated Resize
|
||||
tool in the plan editor changes their size by dragging walls; plus the
|
||||
two findings of the v1.54.3 audit (HP-1543-01/02).
|
||||
|
||||
- **Room resize — a new «Размер» tool in the plan editor** (spec:
|
||||
docs/RESIZE.md). Every wall gets a handle at its midpoint; drag it
|
||||
and the whole wall moves, staying parallel to itself, with grid
|
||||
snap. Works for polygons (L-shapes included) and for legacy
|
||||
rectangles alike.
|
||||
- **Shared walls always move together.** If the dragged wall coincides
|
||||
with a neighbour's boundary, the neighbour follows: your room grows,
|
||||
the neighbour shrinks — gaps and overlaps cannot appear by
|
||||
construction. At T-junctions only the coinciding stretch follows,
|
||||
inserting new corners into the neighbour's outline where needed.
|
||||
- **Live numbers while you drag.** The dragged wall and its two
|
||||
adjacent walls show their lengths in real meters/feet, and the room
|
||||
area in m² updates live at the room centre — for a shared wall, the
|
||||
areas of both rooms.
|
||||
- **The wall stops where it must.** Rooms cannot get thinner than
|
||||
~30 cm (neither yours nor the neighbour's), an outline never crosses
|
||||
itself, a growing wall stops at foreign rooms and island rooms, and
|
||||
doors and windows are anchors: an opening travels with its wall, and
|
||||
a wall carrying openings can never get too short for them.
|
||||
- **A scale frame for the whole room.** Click a room in the resize
|
||||
tool to select it: dragging a corner of the dashed frame scales the
|
||||
outline proportionally, with the same stops applied.
|
||||
- **Esc and Ctrl+Z.** Esc cancels the current drag and puts the
|
||||
original geometry back; one released drag is one undo step, and
|
||||
Ctrl+Z walks back up to 30 of them while the tool is active.
|
||||
- **Fix: room overlap detection missed equal-height rectangles slid
|
||||
over each other.** Found while building the resize stops; the
|
||||
slide-over case now counts as an overlap everywhere the check is
|
||||
consulted, including the draw tool.
|
||||
- **Fix (HP-1543-01): editor zoom stayed on screen after a floor
|
||||
switch made inside the editor.** Exiting an editor on a different
|
||||
floor than the one you started on skipped the viewport restore and
|
||||
left the editor working zoom in view mode. Such an exit now falls
|
||||
back to the current floor's saved view zoom.
|
||||
- **Fix (HP-1543-02): a motion sensor re-tripped mid-flash stayed
|
||||
invisible.** A rapid off→on before the current flash ended kept the
|
||||
old CSS animation timeline, so the second detection played nothing.
|
||||
Every witnessed trip now gets a fresh animation identity and the
|
||||
flash restarts; under reduced motion the static ring stays, as
|
||||
before.
|
||||
|
||||
## v1.54.3 — 2026-08-01
|
||||
|
||||
Patch release: three quality-of-life rounds — icon satellites now follow
|
||||
the per-device size multiplier, editor zoom stopped leaking into view
|
||||
mode, and motion sensors got a visual language of their own.
|
||||
|
||||
- **The per-device size multiplier now scales the icon's satellites
|
||||
too.** Reported by an owner with a screenshot: shrinking a device left
|
||||
the value badge, the temperature/humidity plates, the LQI label, the
|
||||
«new» dot and the alarm ring at full size, towering over the tiny
|
||||
icon. Every satellite now follows the multiplier along with the icon.
|
||||
- **Editor zoom is a working tool, not a saved setting.** Zooming to
|
||||
500% to place a marker precisely and then closing the editor used to
|
||||
drop you into a 500% view mode. Leaving any editor now restores the
|
||||
viewport you had in view mode before you started editing.
|
||||
- **Fix: editor zoom could still resurrect when switching floors.** The
|
||||
editor viewport raced the per-floor viewport store and won: switch
|
||||
floors while editing and the 500% was written down as that floor's
|
||||
saved view, greeting you on the next visit (steps to reproduce came
|
||||
from the owner). Editor zoom is never written to the store anymore.
|
||||
- **Tripped motion and presence sensors signal with a yellow ring.** The
|
||||
rule stays «fill = on»: motion is a one-shot flash — three pulses at
|
||||
the moment of detection, with no pulsing during the cool-down tail —
|
||||
while occupancy/presence hold a static ring for as long as they report
|
||||
someone present.
|
||||
- Stand only: a `services.yaml` for the demo stand's `demo_guard` to
|
||||
keep hassfest green; nothing of it ships in the integration.
|
||||
|
||||
## v1.54.2 — 2026-07-31
|
||||
|
||||
Patch release: the one remaining finding of the v1.54.1 re-audit
|
||||
(HP-1541-01), pinned by regression tests on both sides of the contract.
|
||||
|
||||
- **Fix: a vacuum whose own `selected_map` is `0` split calibration and
|
||||
trail between two map ids.** The v1.54.1 map-id contract («the first value
|
||||
that exists wins, and zero is a value») was applied to the source entity
|
||||
but not to the card's fallback on the vacuum's `selected_map`: the
|
||||
frontend still judged it by truthiness and turned `0` into `default`,
|
||||
while the server recorder stored the run under `0`. Calibration was saved
|
||||
under a key the recorder never used, and the recorded trail never rendered
|
||||
after a reload. The fallback now follows the same not-nullish rule on both
|
||||
sides (`vacMapIdWithFallback` in the card, `resolve_map_id` on the
|
||||
server), with cross-runtime regressions for `selected_map` = `0`, `"0"`
|
||||
and `""`.
|
||||
|
||||
## v1.54.1 — 2026-07-31
|
||||
|
||||
Patch release: everything the adversarial audit of v1.54.0 found
|
||||
(HP-1540-01..06), each fix pinned by a regression test that fails on the
|
||||
old code.
|
||||
|
||||
- **Fix: the first calibration of a freshly discovered vacuum silently did
|
||||
nothing.** Until the device dialog was saved once, the robot had no config
|
||||
marker — yet the «Живая позиция» section was fully interactive, and every
|
||||
handler quietly bailed out while auto-calibration still announced success.
|
||||
Any vacuum edit now materialises the marker itself, and the success toast
|
||||
only appears after the matrix has verifiably landed in the config.
|
||||
- **Fix: a robot whose first map is `map_index: 0` lost its server-side
|
||||
trail.** The backend picked the map id by truthiness and dropped the zero,
|
||||
so the recorded run was stored under a key the card never looked up. Both
|
||||
sides now share one explicit rule — the first value that exists wins, and
|
||||
zero is a value.
|
||||
- **Fix: one robot on two floors recorded history for the last floor only.**
|
||||
The recorder kept a single marker per source entity, so the second
|
||||
placement silently evicted the first. Every marker fed by a source now
|
||||
gets its own copy of the run.
|
||||
- **Fix: auto-calibration ignored plans drawn with rectangle rooms.** The
|
||||
room matcher only accepted polygon outlines and then blamed the room
|
||||
names. Legacy rectangles count like everywhere else in the card.
|
||||
- **Fix: overlapping recorder refreshes leaked state subscriptions.** Two
|
||||
config saves racing each other could both subscribe and strand one
|
||||
callback until restart; refreshes are serialised now and teardown wins
|
||||
over any refresh still in flight.
|
||||
- The «no rooms» / «no match» toasts and docs/VACUUM.md no longer send you
|
||||
to the three-point calibration that no longer exists — they point at
|
||||
«Подогнать вручную», which does.
|
||||
|
||||
## v1.54.0 — 2026-07-31
|
||||
|
||||
### Live robot vacuums
|
||||
|
||||
The plan now shows the robot while it works. The device marker stays where
|
||||
you put it — that is the dock — and a round puck drives the plan in real
|
||||
time, pouring its path out from under itself. Everything is display only:
|
||||
the card never commands the robot.
|
||||
|
||||
- **Calibration without arithmetic.** «Настроить автоматически» matches the
|
||||
robot's room list against your rooms by name and solves the transform in
|
||||
one click. When names do not match, the fit panel lays the robot's rooms
|
||||
over the plan as a dashed ghost: drag it into place, stretch it by the
|
||||
corner handles, quarter-turn and mirror with two buttons. Mirror is on by
|
||||
default — every robot map we have measured flips Y versus the screen.
|
||||
One calibration per robot map, so two floors stay independent.
|
||||
- **The path is recorded server-side.** The integration watches the robot
|
||||
itself, so the trail records with no card open, survives page reloads, and
|
||||
every screen sees the same line. The current run and one previous run are
|
||||
kept — cleaned versus not-yet-cleaned at a glance.
|
||||
- **«Показывать путь робота»**: never / while cleaning (default) / always.
|
||||
Only the last mode also draws the previous run, faded.
|
||||
- The trail never runs ahead of the icon: drawn segments lag one point and
|
||||
the growing tip is glued to the puck's animated centre every frame. It is
|
||||
drawn as a dark halo under a light core, so it stays readable over any
|
||||
room fill. The puck teleports instead of gliding when the view changes —
|
||||
zoom, floor switch or a return to the browser tab.
|
||||
- Adapters: Xiaomi Cloud Map Extractor, dreame-vacuum (Tasshack), Valetudo.
|
||||
Verified against a live Dreame X50 Master.
|
||||
|
||||
### Also
|
||||
|
||||
- **«Свет по источникам» is the default fill for new spaces** and leads the
|
||||
options list. Existing plans are untouched: a space whose owner never
|
||||
chose a fill still renders as before.
|
||||
- **Fixed: the "what to run" search showed no results.** The list is a
|
||||
scrollable box and, as a flex item, collapsed into a 1px sliver — the
|
||||
matches were there, rendered into nothing.
|
||||
|
||||
## v1.53.1 — 2026-07-30
|
||||
|
||||
- **Fix: the "what to run" search showed no results.** The results were
|
||||
there — the list is a scrollable box, and as a flex item inside the dialog
|
||||
body it collapsed into a 1px sliver, so 26 matching automations rendered
|
||||
into nothing visible. Reported within minutes of v1.53.0 by the owner. The
|
||||
list keeps its height now; the smoke measures that height instead of
|
||||
merely counting DOM rows, which is why it passed the broken build.
|
||||
|
||||
## v1.53.0 — 2026-07-30
|
||||
|
||||
**A tap can run your automation** (owner's spec)
|
||||
|
||||
- **New tap action: "Run automation/script/scene".** We cannot know every
|
||||
exotic device, but you know what a tap on it should do: pick any
|
||||
automation, script or scene from a searchable list, and the tap runs it —
|
||||
`automation.trigger`, `script.turn_on` or `scene.turn_on` per kind, with a
|
||||
"Started" toast. A script is the idiomatic HA "action", so all three
|
||||
runnable kinds are offered — no trigger-less dummy automations needed.
|
||||
Saving refuses a run action without a target; a target deleted later warns
|
||||
in the dialog and toasts safely on tap.
|
||||
- **"Ask for confirmation" checkbox** — the accidental-tap guard, available
|
||||
for any state-changing action: toggle and run alike, wall-switch markers
|
||||
with bound targets included. The dialog is the card's own (Esc, backdrop
|
||||
or Cancel = nothing happens), so it works on a wall tablet.
|
||||
- **Curtains toggle natively:** covers and valves joined the card-wide
|
||||
toggle domains — with one deliberate exception: garage doors, doors and
|
||||
gates (cover device classes garage/door/gate) stay OUT of the default
|
||||
toggle, an accidental tap must not open the driveway. An explicit
|
||||
per-device toggle remains the owner's conscious choice, and locks/alarm
|
||||
panels stay untouchable from the plan, as always.
|
||||
|
||||
## v1.52.2 — 2026-07-29
|
||||
|
||||
**From the v1.52.1 review** (no runtime changes — test and wording quality)
|
||||
|
||||
@@ -6,6 +6,271 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.55.2 — 2026-08-02
|
||||
|
||||
Патч-релиз: спокойное первое открытие — без вспышки зума и прыжков
|
||||
макета, пока Home Assistant догружается, — плюс новая настройка цвета
|
||||
фона и две полировки редактора.
|
||||
|
||||
- **План больше не мигает видом по умолчанию перед сохранённым зумом**
|
||||
(HP-1551). Сохранённый зум применялся на такт позже первого рендера:
|
||||
карточка на кадр рисовала стандартную подгонку и лишь потом прыгала к
|
||||
вашей позиции. Теперь он применяется синхронно, до первой отрисовки —
|
||||
план появляется ровно там, где вы его оставили.
|
||||
- **Вуаль первого открытия прячет план, пока макет не устаканится**
|
||||
(HP-1552). Пока Home Assistant догружает свои панели, высота сцены
|
||||
может меняться несколько раз, и план заметно прыгал вместе с ней.
|
||||
Теперь карточка ждёт под тёмной вуалью с пульсирующим контуром домика,
|
||||
пока высота не перестанет меняться, и показывает план уже на месте.
|
||||
В режиме киоска вуаль не показывается вовсе.
|
||||
- **Новая настройка: цвет фона вокруг плана** (HP-1554). Области вокруг
|
||||
плана теперь можно задать любой цвет — в общих настройках для всей
|
||||
карточки или отдельно для каждого пространства. Бэкенд проверяет
|
||||
значение как hex-цвет #rrggbb.
|
||||
- **Круглые концы и стыки линий в редакторе подложки** (HP-1553). Штрихи
|
||||
обрывались и стыковались квадратными срезами; теперь концы линий и
|
||||
углы скруглены — обведённые стены и контуры выглядят аккуратно.
|
||||
- **Ручки стен в инструменте Resize теперь выглядят по делу.** Они вдвое
|
||||
меньше прежних, несут значок «стенка со стрелочками», повёрнутый вдоль
|
||||
своей стены, и показывают курсор grab — при этом зона клика осталась
|
||||
прежней, большой.
|
||||
|
||||
## v1.55.1 — 2026-08-01
|
||||
|
||||
Патч-релиз: четыре находки аудита v1.55.0, все — в новом инструменте
|
||||
ресайза комнат (HP-1550-01..04).
|
||||
|
||||
- **Отложенное сохранение больше не утаскивает живое перетаскивание на
|
||||
сервер** (HP-1550-01). Превью ресайза писалось прямо в общий объект
|
||||
конфигурации, и отложенная запись, оставшаяся в очереди от прошлой
|
||||
правки, могла сфотографировать его посреди перетаскивания — ресайз,
|
||||
который вы затем отменили по Esc, тихо оставался на сервере и
|
||||
возвращался после перезагрузки. Теперь живая геометрия живёт в
|
||||
отдельном слое, который видит только отрисовка; в конфигурацию она
|
||||
попадает ровно один раз — при отпускании ручки.
|
||||
- **Прерванное перетаскивание отменяется, а не сохраняется**
|
||||
(HP-1550-03). Когда систему прерывает сам поток ввода — переключение
|
||||
приложения, ладонь на планшете — инструмент считал это отпусканием и
|
||||
сохранял недоделанную геометрию. Теперь такие прерывания идут тем же
|
||||
путём, что и Esc: исходная геометрия возвращается, без шага отмены и
|
||||
без записи.
|
||||
- **Дверь посреди стены больше не мешает менять её размер**
|
||||
(HP-1550-04). Невидимая зона клика двери ложилась поверх ручки стены,
|
||||
и такую стену нельзя было схватить ни у одной из комнат. В
|
||||
инструменте ресайза ручки теперь главные в hit-тесте (проёмы там всё
|
||||
равно не редактируются — они просто едут вместе со стеной), а во всех
|
||||
остальных инструментах редактора проёмы кликаются как раньше.
|
||||
- **Минимум ~30 см теперь держится для комнаты любой формы**
|
||||
(HP-1550-02). Старая проверка мерила только параллельные
|
||||
противоположные стены: треугольную комнату можно было сплющить в
|
||||
щепку, а повёрнутая комната при масштабировании незаметно уводила
|
||||
свою реальную короткую сторону ниже порога. Обе меры теперь не
|
||||
зависят от ориентации: перетаскивание стены упирается в любое
|
||||
препятствие на её пути (вершину треугольника, косую стену), а рамка
|
||||
масштабирования использует истинную минимальную ширину комнаты.
|
||||
Комнаты, уже более тонкие, по-прежнему можно улучшать, но не ухудшать.
|
||||
|
||||
## v1.55.0 — 2026-08-01
|
||||
|
||||
Минорный релиз: комнаты больше не высечены в камне — новый инструмент
|
||||
«Размер» в редакторе плана меняет их габариты перетаскиванием стен;
|
||||
плюс обе находки аудита v1.54.3 (HP-1543-01/02).
|
||||
|
||||
- **Ресайз комнат — новый инструмент «Размер» в редакторе плана**
|
||||
(спека: docs/RESIZE.md). У каждой стены появляется ручка в середине;
|
||||
тяните её — и стена едет целиком, оставаясь параллельной себе, с
|
||||
привязкой к сетке. Работает и для полигонов (включая Г-образные), и
|
||||
для старых прямоугольников.
|
||||
- **Общие стены всегда двигаются вместе.** Если перетаскиваемая стена
|
||||
совпадает с границей соседа, сосед следует за ней: ваша комната
|
||||
растёт — соседняя сжимается, щели и наложения не появляются по
|
||||
построению. На Т-стыках следует только совпадающий отрезок, при
|
||||
необходимости в контур соседа вставляются новые вершины.
|
||||
- **Живые цифры во время перетаскивания.** Перетаскиваемая стена и две
|
||||
смежные показывают длины в реальных метрах/футах, а площадь комнаты
|
||||
в м² обновляется в центре комнаты вживую — для общей стены — площади
|
||||
обеих комнат.
|
||||
- **Стена упирается там, где должна.** Комната не может стать тоньше
|
||||
~30 см (ни своя, ни соседская), контур не пересекает сам себя,
|
||||
растущая стена останавливается о чужие комнаты и острова, а двери и
|
||||
окна — якоря: проём едет вместе со стеной, и стена с проёмами не
|
||||
может стать для них слишком короткой.
|
||||
- **Рамка масштабирования всей комнаты.** Клик по комнате в
|
||||
инструменте выбирает её: перетаскивание уголка пунктирной рамки
|
||||
пропорционально масштабирует контур — с теми же упорами.
|
||||
- **Esc и Ctrl+Z.** Esc отменяет текущее перетаскивание и возвращает
|
||||
исходную геометрию; одно отпускание ручки — один шаг отмены, Ctrl+Z
|
||||
откатывает до 30 шагов, пока инструмент активен.
|
||||
- **Исправлено: детекция наложения комнат пропускала равновысокие
|
||||
прямоугольники, надвинутые друг на друга.** Найдено при постройке
|
||||
упоров ресайза; slide-over теперь считается наложением везде, где
|
||||
используется эта проверка, включая инструмент рисования.
|
||||
- **Исправлено (HP-1543-01): редакторский зум оставался на экране
|
||||
после смены этажа внутри редактора.** Выход из редактора на другом
|
||||
этаже, чем тот, где начинали, пропускал восстановление вьюпорта и
|
||||
оставлял рабочий зум редактора в режиме просмотра. Теперь такой
|
||||
выход откатывается к сохранённому view-зуму текущего этажа.
|
||||
- **Исправлено (HP-1543-02): датчик движения, сработавший повторно
|
||||
посреди вспышки, оставался невидимым.** Быстрое off→on до конца
|
||||
текущей вспышки не перезапускало CSS-анимацию — второе срабатывание
|
||||
ничего не проигрывало. Теперь каждый зафиксированный trip получает
|
||||
новую идентичность анимации, и вспышка стартует заново; при
|
||||
prefers-reduced-motion остаётся статичное кольцо, как и раньше.
|
||||
|
||||
## v1.54.3 — 2026-08-01
|
||||
|
||||
Патч-релиз: три раунда качества жизни — спутники значка теперь слушаются
|
||||
пер-девайсного множителя размера, редакторский зум перестал протекать в
|
||||
режим просмотра, а датчики движения получили собственный визуальный язык.
|
||||
|
||||
- **Пер-девайсный множитель размера теперь масштабирует и спутники
|
||||
значка.** Репорт владельца со скриншотом: у уменьшенного устройства
|
||||
бейдж значения, плашки температуры и влажности, подпись LQI, точка
|
||||
«new» и кольцо тревоги оставались полноразмерными и нависали над
|
||||
крошечным значком. Теперь все спутники следуют за множителем вместе со
|
||||
значком.
|
||||
- **Редакторский зум — рабочий инструмент, а не сохранённая настройка.**
|
||||
Приблизить до 500%, чтобы точно поставить маркер, и закрыть редактор
|
||||
раньше означало оказаться в режиме просмотра на тех же 500%. Выход из
|
||||
любого редактора теперь восстанавливает тот вид, который был в режиме
|
||||
просмотра до начала правок.
|
||||
- **Исправлено: редакторский зум всё ещё «воскресал» при переключении
|
||||
этажей.** Редакторский вьюпорт гонялся с пер-этажным хранилищем вида и
|
||||
побеждал: переключите этаж во время правок — и 500% записывались как
|
||||
сохранённый вид этого этажа и встречали вас в следующий раз (шаги
|
||||
воспроизведения — от владельца). Теперь редакторский зум в хранилище
|
||||
не пишется никогда.
|
||||
- **Сработавшие датчики движения и присутствия сигнализируют жёлтым
|
||||
кольцом.** Правило прежнее — «подложка = включено»: движение — разовая
|
||||
вспышка, три удара в момент обнаружения, без пульсации на хвосте
|
||||
cool-down; occupancy/presence держат статичное кольцо, пока
|
||||
докладывают о присутствии.
|
||||
- Только для стенда: `services.yaml` для стендового `demo_guard`, чтобы
|
||||
hassfest оставался зелёным; в саму интеграцию это не попадает.
|
||||
|
||||
## v1.54.2 — 2026-07-31
|
||||
|
||||
Патч-релиз: единственная оставшаяся находка повторного аудита v1.54.1
|
||||
(HP-1541-01), закреплена регрессиями с обеих сторон контракта.
|
||||
|
||||
- **Исправлено: пылесос с собственным `selected_map: 0` делил калибровку и
|
||||
след между двумя id карты.** Контракт map id из v1.54.1 («побеждает
|
||||
первое существующее значение, и ноль — значение») был применён к
|
||||
source-сущности, но не к фолбэку карточки на `selected_map` самого
|
||||
пылесоса: фронтенд по-прежнему судил по «истинности» и превращал `0` в
|
||||
`default`, тогда как серверный рекордер писал уборку под `0`. Калибровка
|
||||
сохранялась под ключом, которого рекордер не использовал, а записанный
|
||||
след после перезагрузки не отображался. Теперь фолбэк живёт по тому же
|
||||
правилу «не-null» с обеих сторон (`vacMapIdWithFallback` в карточке,
|
||||
`resolve_map_id` на сервере), с кросс-регрессиями для `selected_map` =
|
||||
`0`, `"0"` и `""`.
|
||||
|
||||
## v1.54.1 — 2026-07-31
|
||||
|
||||
Патч-релиз: всё, что нашёл adversarial-аудит v1.54.0 (HP-1540-01..06),
|
||||
каждый фикс закреплён регрессией, падающей на старом коде.
|
||||
|
||||
- **Исправлено: первая калибровка только что обнаруженного пылесоса молча
|
||||
ничего не делала.** Пока диалог устройства ни разу не сохранён, у робота
|
||||
нет маркера в конфиге — а раздел «Живая позиция» уже полностью
|
||||
интерактивен: каждый обработчик тихо выходил, но автокалибровка всё равно
|
||||
объявляла успех. Теперь любая правка пылесоса сама создаёт маркер, а тост
|
||||
успеха появляется только после того, как матрица действительно легла в
|
||||
конфиг.
|
||||
- **Исправлено: робот с первой картой `map_index: 0` терял серверный
|
||||
след.** Бэкенд выбирал id карты по «истинности» и выбрасывал ноль, так
|
||||
что записанная уборка хранилась под ключом, который карточка не искала.
|
||||
Теперь обе стороны живут по одному явному правилу: побеждает первое
|
||||
существующее значение, и ноль — значение.
|
||||
- **Исправлено: один робот на двух этажах писал историю только для
|
||||
последнего этажа.** Рекордер держал по одному маркеру на источник, и
|
||||
второе размещение молча вытесняло первое. Теперь каждый маркер источника
|
||||
получает свою копию уборки.
|
||||
- **Исправлено: автокалибровка не видела планы из прямоугольных комнат.**
|
||||
Сопоставление принимало только полигоны, а потом сваливало вину на имена
|
||||
комнат. Старые прямоугольники считаются, как и во всей остальной
|
||||
карточке.
|
||||
- **Исправлено: пересекающиеся обновления рекордера текли подписками.**
|
||||
Два одновременных сохранения конфига могли подписаться оба и бросить один
|
||||
callback до перезапуска; обновления теперь сериализованы, а teardown
|
||||
выигрывает у любого ещё летящего обновления.
|
||||
- Тосты «нет комнат» / «имена не совпали» и docs/VACUUM.md больше не
|
||||
отправляют к несуществующей калибровке по трём точкам — они ведут к
|
||||
«Подогнать вручную», которая существует.
|
||||
|
||||
## v1.54.0 — 2026-07-31
|
||||
|
||||
### Роботы-пылесосы вживую
|
||||
|
||||
План теперь показывает робота за работой. Маркер устройства остаётся там,
|
||||
куда вы его поставили — это база, — а по плану в реальном времени едет
|
||||
круглая шайба, «выливая» путь из-под себя. Только отображение: карточка
|
||||
ничего роботу не командует.
|
||||
|
||||
- **Калибровка без арифметики.** «Настроить автоматически» сопоставляет
|
||||
комнаты робота с вашими по именам и решает привязку в один клик. Если
|
||||
имена не совпали, панель подгонки кладёт комнаты робота на план
|
||||
пунктирным призраком: перетащите на место, растяните за уголки, поворот
|
||||
на 90° и зеркало — двумя кнопками. Зеркало включено по умолчанию: у всех
|
||||
измеренных нами роботов ось Y перевёрнута относительно экрана. Своя
|
||||
калибровка на каждую карту робота, поэтому два этажа не мешают друг другу.
|
||||
- **Путь пишется на сервере.** Интеграция следит за роботом сама, поэтому
|
||||
след записывается даже без открытой карточки, переживает перезагрузку
|
||||
страницы, и все экраны видят одну и ту же линию. Хранятся текущая уборка
|
||||
и одна предыдущая — сразу видно, где робот уже прошёл, а где ещё нет.
|
||||
- **«Показывать путь робота»**: никогда / во время уборки (по умолчанию) /
|
||||
всегда. Прошлая уборка бледной линией рисуется только в последнем режиме.
|
||||
- След никогда не обгоняет иконку: отрисованные отрезки отстают на точку, а
|
||||
растущий кончик каждый кадр приклеен к центру едущей шайбы. Рисуется
|
||||
тёмным ореолом со светлой сердцевиной — читается на любой заливке комнат.
|
||||
При смене вида (зум, этаж, возврат на вкладку) шайба телепортируется, а
|
||||
не едет через весь план.
|
||||
- Поддержаны Xiaomi Cloud Map Extractor, dreame-vacuum (Tasshack) и
|
||||
Valetudo. Проверено на живом Dreame X50 Master.
|
||||
|
||||
### Кроме того
|
||||
|
||||
- **«Свет по источникам» — режим по умолчанию для новых пространств** и
|
||||
первый в списке. Существующие планы не трогаем: пространство, где режим
|
||||
не выбирали, выглядит как раньше.
|
||||
- **Исправлено: поиск «что запускать» не показывал результатов.** Список —
|
||||
прокручиваемый блок, и как flex-элемент он схлопывался в полоску 1px:
|
||||
совпадения были, но отрисовывались в ничто.
|
||||
|
||||
## v1.53.1 — 2026-07-30
|
||||
|
||||
- **Фикс: поиск «что запускать» не показывал результатов.** Результаты были —
|
||||
список это прокручиваемый блок, и как flex-элемент внутри тела диалога он
|
||||
схлопывался в полоску 1px: 26 подходящих автоматизаций отрисовывались в
|
||||
ничто. Найдено владельцем через минуты после v1.53.0. Теперь список держит
|
||||
высоту; смок измеряет эту высоту, а не просто считает строки в DOM —
|
||||
именно поэтому он и пропустил сломанную сборку.
|
||||
|
||||
## v1.53.0 — 2026-07-30
|
||||
|
||||
**Нажатие может запускать вашу автоматизацию** (ТЗ владельца)
|
||||
|
||||
- **Новое действие по нажатию: «Запустить автоматизацию/скрипт/сцену».** Мы
|
||||
не можем знать каждое экзотическое устройство, но вы знаете, что должен
|
||||
делать тап по нему: выберите любую автоматизацию, скрипт или сцену из
|
||||
списка с поиском — тап её запустит (`automation.trigger`,
|
||||
`script.turn_on` или `scene.turn_on` по типу цели, с тостом «Запущено»).
|
||||
Скрипт — идиоматичное «действие» HA, поэтому предлагаются все три
|
||||
запускаемых типа: не нужны автоматизации-пустышки без триггера.
|
||||
Сохранение без цели блокируется; удалённая позже цель подсвечивается в
|
||||
диалоге и безопасно отвечает тостом при тапе.
|
||||
- **Чекбокс «Спрашивать подтверждение»** — защита от случайных нажатий для
|
||||
любого действия, меняющего состояние: и toggle, и запуск, включая
|
||||
настенные выключатели с привязанными целями. Диалог собственный (Esc,
|
||||
клик мимо или «Отмена» — ничего не происходит), поэтому работает и на
|
||||
настенном планшете.
|
||||
- **Шторы переключаются нативно:** cover и valve добавлены в домены
|
||||
card-wide toggle — с одним осознанным исключением: гаражные ворота, двери
|
||||
и калитки (классы garage/door/gate) остаются ВНЕ дефолтного переключения —
|
||||
случайный тап не должен открыть гараж. Явный per-device toggle остаётся
|
||||
осознанным выбором владельца, а замки и охранные панели, как и всегда,
|
||||
с плана неприкосновенны.
|
||||
|
||||
## v1.52.2 — 2026-07-29
|
||||
|
||||
**По ревью v1.52.1** (рантайм не менялся — качество теста и формулировок)
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ An interactive floor plan for Home Assistant delivered as one HACS package:
|
||||
a storage **integration** (server-side config, WS API, file uploads, auth) + a
|
||||
**Lovelace card** (rendering, room markup editor, drag layout, zoom, live states,
|
||||
temperature, Zigbee LQI, device metadata with PDF manuals, virtual markers,
|
||||
en/ru localization). GUI-first: no YAML, no hand-made SVG.
|
||||
live robot vacuums, en/ru localization). GUI-first: no YAML, no hand-made SVG.
|
||||
|
||||
## Competitive landscape (stars verified 2026-07-06)
|
||||
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
# Room resize — the spec (source of truth)
|
||||
|
||||
Status: approved by the owner 2026-08-01. Dev-branch feature, **no
|
||||
release**. Scope decisions final: a dedicated tool mode, wall-drag with
|
||||
shared walls always moving together, a corner-scale frame for the
|
||||
selected room, live numbers (wall lengths + room areas), grid snap,
|
||||
Esc-cancel, one drag = one undo step.
|
||||
|
||||
## Principle
|
||||
|
||||
Until now room geometry could only be changed by split/merge or by
|
||||
redrawing the outline — there was no vertex or wall dragging at all
|
||||
(the `.rlhandle` corners belong to the room LABEL card, not to the
|
||||
room). Resize adds exactly two mechanisms, both living ONLY inside a
|
||||
dedicated Plan-editor tool «Изменение размера комнат» (`_tool ===
|
||||
'resize'`). In every other tool the plan looks and behaves exactly as
|
||||
before — no handles, no new hit areas.
|
||||
|
||||
## Mechanism A — wall drag
|
||||
|
||||
- Every visible room shows a small handle at the midpoint of every
|
||||
wall (handles for all rooms at once — owner's UX pick). The visible
|
||||
glyph is a compact icon — a wall segment with two arrows pointing
|
||||
perpendicular to it (the directions the wall drags), rotated to the
|
||||
wall's orientation; accent ink over a `--hp-bg` halo. It is half the
|
||||
size of the old circle, but the HIT area is an invisible circle of
|
||||
the original finger-sized radius (derived from `view.w` like
|
||||
`.vacfithandle`), so touch targets did not shrink. Cursor: `grab`,
|
||||
`grabbing` while dragging.
|
||||
- Dragging a handle moves the wall along its outward normal; **both
|
||||
ends of the edge translate together** (the wall stays parallel to
|
||||
itself; adjacent walls stretch/shrink). Works for any polygon
|
||||
(L-shaped included) and for legacy `x/y/w/h` rectangles — those are
|
||||
converted through `roomPoly` and are **saved back as `poly`**.
|
||||
- The moved wall position snaps to the drawing grid (`snapToGrid`,
|
||||
same pitch as the draw tool).
|
||||
- **Handles own the hit test** (HP-1550-04): inside the resize tool
|
||||
openings are not editable — their transparent hit area is inert
|
||||
(`pointer-events: none`) and the resize layer renders above the
|
||||
openings, so a door sitting exactly at the midpoint of a wall can
|
||||
never shadow that wall's handle. A drag of such a wall carries the
|
||||
door along through the normal anchor pipeline; clicking over a door
|
||||
falls through to room picking. Every other Plan tool keeps the
|
||||
openings interactive exactly as before.
|
||||
|
||||
## Shared walls — ALWAYS together
|
||||
|
||||
If a stretch of the dragged wall coincides with a neighbour's boundary
|
||||
(collinear overlap with an epsilon, the `sharedBoundary` notion), the
|
||||
coinciding stretches of the neighbour move synchronously: your room
|
||||
grows — the neighbour shrinks. Gaps and overlaps cannot appear by
|
||||
construction.
|
||||
|
||||
Partial contact (T-junctions): only the coinciding stretch of the
|
||||
neighbour moves. Where the stretch ends inside a neighbour wall, new
|
||||
vertices are inserted into the neighbour outline, which may legally
|
||||
become L-shaped. All of this is shown as a live preview during the
|
||||
drag. On commit collinear leftovers are simplified away
|
||||
(`simplifyPoly`), so geometry stays clean.
|
||||
|
||||
## Stops (the wall stops dead)
|
||||
|
||||
1. **Minimum size** — neither the own room nor a shrinking neighbour
|
||||
may get thinner than ~30 cm (`MIN_ROOM_CM`, expressed in canvas
|
||||
units through `cell_cm`). The measure is orientation-independent
|
||||
(HP-1550-02): for a wall drag it is the smallest perpendicular
|
||||
distance from the moved stretch to any part of the room boundary
|
||||
inside the band the stretch sweeps along its normal — vertices and
|
||||
crossing walls count whether parallel or not (a triangle's apex, a
|
||||
slanted obstacle), while collinear remainders and the step corners
|
||||
a T-junction inserts at the very ends of the stretch do not. For
|
||||
the scale frame it is the TRUE minimum width of the outline
|
||||
(rotating calipers over the convex hull) scaled by `k` — the
|
||||
axis-aligned bbox is never consulted, so rotation cannot hide the
|
||||
short side. Rooms that are ALREADY thinner keep their clearance
|
||||
(the drag may improve it, never worsen it).
|
||||
2. **Self-intersection** — a wall never passes through the opposite
|
||||
side; the outline must stay a simple polygon with its orientation
|
||||
and a positive area.
|
||||
3. **Foreign rooms** — a growing wall stops when it would overlap a
|
||||
room that is not a shared-wall neighbour (`roomsOverlap`; touching
|
||||
walls are legal, crossing is not).
|
||||
4. **Island rooms** — islands inside the room (`islandsOf`) must stay
|
||||
fully inside; a wall shrinking onto an island stops.
|
||||
5. **Openings are anchors** — a door/window ON the moving stretch
|
||||
travels with the wall (its `openings[].x/y` centre is shifted, the
|
||||
angle is unchanged). A wall that carries openings cannot get too
|
||||
short for them: every opening previously sitting on a wall of an
|
||||
affected room must still fit fully on some wall afterwards — for
|
||||
the own room AND for the neighbour.
|
||||
|
||||
## Mechanism B — the scale frame
|
||||
|
||||
- In the resize tool a click inside a room SELECTS it: a dashed
|
||||
bounding frame with 4 corner handles appears.
|
||||
- Dragging a corner scales ALL vertices proportionally (uniform
|
||||
similarity) about the opposite bbox corner — the same maths family
|
||||
as the vacuum fit panel (`reanchorFit`), only without rotation.
|
||||
- The same stops apply (minimum size, foreign overlap, islands,
|
||||
openings; self-intersection is impossible under a similarity).
|
||||
- **The one exception to «shared walls always together»:** a scale
|
||||
breaks collinear coincidence (walls move apart at an angle-preserving
|
||||
ratio, not along a normal), so neighbours are NOT dragged along.
|
||||
Growing into a neighbour simply stops the scale (the neighbour is a
|
||||
wall to hit); shrinking away from a neighbour legally opens a gap.
|
||||
- Openings exclusive to the scaled room follow the transform
|
||||
(position scales, physical length does not); openings on a wall
|
||||
shared with an unchanged neighbour stay with the neighbour's wall.
|
||||
|
||||
## Live numbers
|
||||
|
||||
While a handle is being dragged:
|
||||
|
||||
- length badges (`.measurelabel` style, `segmentCm`/`formatLength`,
|
||||
metric or imperial per the HA unit system) on the dragged wall and
|
||||
its two adjacent walls;
|
||||
- the room area in m² (`polygonArea` × scale²) at the room centre,
|
||||
live; when a shared wall is dragged — the areas of BOTH rooms
|
||||
(owner picked «стены + площадь»);
|
||||
- Esc cancels the current drag and puts the original geometry back;
|
||||
- releasing the handle (pointerup) commits: one write through the
|
||||
standard debounced `_saveConfig` path;
|
||||
- `pointercancel` / `lostpointercapture` (the system interrupted the
|
||||
stream: app switch, palm rejection) takes the CANCEL path, never the
|
||||
commit path — snapshot geometry back, no undo step, no write
|
||||
(HP-1550-03).
|
||||
|
||||
## Preview vs commit (HP-1550-01)
|
||||
|
||||
The live drag preview never touches the shared `_serverCfg`: it lives
|
||||
in a separate overlay (`_rszPreview`) that `_curSpaceCfg`/`_renderCfg`
|
||||
substitute into every render. Config writes are serialized and read
|
||||
`_serverCfg` at the moment they run (HP-1454-03), so a debounced write
|
||||
still queued from a previous edit can fire mid-drag — with the overlay
|
||||
it carries only committed geometry. The overlay moves into the real
|
||||
config exactly once, on pointerup; a cancel (Esc, pointercancel) just
|
||||
drops the overlay, leaving nothing to restore and nothing to write.
|
||||
|
||||
## Undo
|
||||
|
||||
One operation (handle release that changed something) = one undo step.
|
||||
The plan editor has no committed-operation undo stack (Ctrl+Z/Esc only
|
||||
walk back draw/split points), so the resize tool keeps its own stack
|
||||
of pre-drag snapshots (rooms + openings of the space, capped at 30)
|
||||
and Ctrl+Z/⌘Z pops it while the tool is active.
|
||||
|
||||
## Out of scope / invariants
|
||||
|
||||
- Device positions are not touched; the room settings button (pole of
|
||||
inaccessibility) recomputes itself from the new outline.
|
||||
- Saving goes through the standard config path (`houseplan/config/set`
|
||||
with `expected_rev`); backend validation already covers polygons
|
||||
(`_GEOM` ±4, `MAX_POLY_POINTS` 500) — inserted neighbour vertices are
|
||||
just polygon points, openings keep their schema, nothing new to
|
||||
validate server-side.
|
||||
- Touch: handles are finger-sized, use pointer capture and swallow
|
||||
`pointerdown`, so the stage pan/pinch never fights a handle drag.
|
||||
- The label-card corners (`.rlhandle`, `_rlResizeDown`) are untouched.
|
||||
|
||||
## Geometry home
|
||||
|
||||
All pure geometry lives in `src/resize.ts` (edge normals, edge move,
|
||||
shared-span search and vertex insertion, all stops — including the
|
||||
orientation-independent `minSpanClearance` band measure and the
|
||||
`minPolyWidth` calipers width — the scale clamp, area formatting)
|
||||
under node:test units in `test/resize.test.mjs`;
|
||||
`src/houseplan-card.ts` only wires pointers, the preview overlay,
|
||||
badges and undo.
|
||||
+7
-1
@@ -57,7 +57,13 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
|
||||
mapping (regex/domain/device_class → mdi icon) stored in config, shipping EN+RU
|
||||
defaults; drop dacha-specific patterns from code.
|
||||
- [x] **Click actions** per device (v1.13.0, simplified v1.38.1): card / more-info /
|
||||
toggle, with the lock/alarm safety model.
|
||||
toggle, with the lock/alarm safety model; running an automation/script/scene
|
||||
with an optional confirmation landed in v1.53.0.
|
||||
- [x] **Live robot vacuums** (docs/VACUUM.md): a puck driving the plan over a
|
||||
solved affine transform, one-click calibration by room names or a
|
||||
drag-and-stretch fit panel, server-recorded trails (current + previous run)
|
||||
with never/cleaning/always display modes. Adapters: Xiaomi Cloud Map
|
||||
Extractor, Tasshack dreame-vacuum, Valetudo. Display only — no commands.
|
||||
- [x] **Theming**: light-theme pass done in v1.13.0; HA theme variables
|
||||
everywhere, optional per-space background color.
|
||||
- [ ] Multi-instance question: keep single-instance (one house) but support **multiple
|
||||
|
||||
+6
-4
@@ -11,18 +11,20 @@
|
||||
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
|
||||
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
|
||||
|
||||
## Snapshot (2026-07-29)
|
||||
## Snapshot (2026-08-01)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.52.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.55.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.52.2** via direct copy (HACS custom repo also installed) |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
|
||||
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.55.2** via direct copy (HACS custom repo also installed) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
|
||||
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
|
||||
| Demo stand | **https://demo.houseplan.tech** — public, login `demo`/`demo`, resets to a pristine synthetic home every hour. **https://dev.houseplan.tech** — closed (basic auth), auto-deploys the `dev` branch every 10 min. Host: `ssh -i ~/.ssh/hp_stand hp@135.106.166.146`; layout, seeds and gotchas in the memory note `houseplan-demo-stand`. Since 2026-07-31 the stand covers most of the manual checklist: a scripted robot vacuum (`demo/stand/demo_robot` — Tasshack-shaped map sensor, serpentine run, pre-solved calibration, seeded server trail), Zigbee-style LQI template sensors, hand/auto-triggered leak+smoke alarms, an hvac_action climate marker and working script/scene/automation targets for tap-run. The stand-specific how-to-check guide is **docs/TESTING-DEMO.md** |
|
||||
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
|
||||
| Product scope | docs/SCOPE.md (2026-07-22) is the feature guard rail — check before accepting any feature |
|
||||
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
# Чек-лист ручного тестирования на публичном демо-стенде
|
||||
|
||||
> **Стенд:** https://demo.houseplan.tech — вход **demo / demo** (админ).
|
||||
> Dev-версия: https://dev.houseplan.tech (basic auth `hp` / `houseplan-dev-2026`, дальше тот же demo/demo).
|
||||
>
|
||||
> **Стенд сбрасывается каждый час** к эталонному состоянию — это фича: ломайте
|
||||
> смело, удаляйте комнаты, заливайте планы, через час всё вернётся. Из этого же
|
||||
> следует: долгоживущие проверки («файл исчез через сутки», «пережило рестарт»)
|
||||
> на стенде не живут.
|
||||
>
|
||||
> Источник истины — [TESTING.md](TESTING.md): формулировки пунктов и их
|
||||
> `[auto:…]`-маркеры смотреть там. Здесь каждый пункт сокращён до сути и дополнен
|
||||
> строкой «Стенд: …» — где и как ткнуть именно на демо-стенде.
|
||||
>
|
||||
> **Демо-дом:** дашборд «House plan» (виды: План / Киоск / Схема), пространства
|
||||
> «1 этаж» (Гостиная, Кухня, Спальня, Кабинет, Прихожая) и «Двор». Ключевые
|
||||
> демо-устройства: робот-пылесос (Прихожая, симулятор `demo_robot`), датчик
|
||||
> протечки (Кухня, включается помощником `input_boolean.demo_leak`), датчик дыма
|
||||
> (Спальня, сам срабатывает каждые 10 минут на 30 с), «Тёплый пол» (Кабинет,
|
||||
> climate.heatpump, hvac_action=heating), 4 «Zigbee»-термометра + дверной датчик
|
||||
> с атрибутом `linkquality` (отличный/средний/слабый сигнал по комнатам),
|
||||
> маркер «Открыть шторы» (tap → запуск скрипта с подтверждением).
|
||||
|
||||
## Вводные пункты (v1.43.x–v1.44.x)
|
||||
|
||||
- Smoke-harness сам по себе — Стенд: не проверяется (это CI), только локально.
|
||||
- Шестерёнка «⚙ Room» на каждой карточке комнаты в редакторе плана — Стенд: открыть «Редактор плана», убедиться что у всех 5 комнат есть кнопка ⚙ фиксированного размера, открывает настройки комнаты.
|
||||
- Метрики 0.75 от имени комнаты — Стенд: включить метрики в настройках пространства (⚙ у «1 этаж») и посмотреть подпись под именем комнаты.
|
||||
- Тултип не появляется после touch — Стенд: открыть с планшета/телефона, потыкать комнаты и иконки — hover-тултипов быть не должно.
|
||||
- Флаг «источник света» у выключателя — Стенд: у «Decorative Lights» (Гостиная) в диалоге устройства включить «Это источник света» — при включении розетка светится в glow-заливке.
|
||||
- Карточка устройства: управляемые сущности сверху, ≥30 px, замки не переключаются — Стенд: тапнуть «Котёл» или медиаплеер; для замка — открыть карточку двери (бейдж замка на двери в Прихожей).
|
||||
- Инвариант замков на всех путях — Стенд: дверь в Прихожей: Unlock просит подтверждение, Lock — нет; иконка замка никогда не тогглится тапом.
|
||||
- Транзакционная миграция вложений — Стенд: прикрепить PDF к устройству, перепривязать маркер на другую сущность — файл открывается после перепривязки.
|
||||
- Планы и PDF в реальном браузере — Стенд: залить план в настройках пространства (любой PNG/SVG), DevTools → Network: `/api/houseplan/content/...` = 200 с `authSig`, тот же URL без authSig = 401.
|
||||
- Единая политика прав (may_write) — Стенд: не проверяется напрямую (нужен не-админ; на стенде один пользователь demo-админ) — покрыто tests_backend.
|
||||
- NaN/Infinity и лимиты openings — Стенд: не проверяется руками (нужен ручной WS-вызов) — tests_backend.
|
||||
- Захват указателя при drag, декор не утаскивается за границы — Стенд: «Редактор фона», нарисовать фигуру и попытаться утащить её далеко за план.
|
||||
- Скрытые термометры питают карточку комнаты — Стенд: скрыть «Термометр в гостиной» (галка «скрыть с плана») — температура в карточке Гостиной остаётся.
|
||||
- Тултип комнаты без «открыть area» — Стенд: навести на комнату с десктопа: имя + температура/сигнал, никакого «открыть».
|
||||
|
||||
## Матрица окружений ★
|
||||
|
||||
- Chrome/Edge desktop — Стенд: да, просто открыть.
|
||||
- Firefox desktop — Стенд: да, со своей машины.
|
||||
- Safari (macOS) — Стенд: да, при наличии Mac.
|
||||
- HA Companion Android (холодный старт!) — Стенд: да: добавить `https://demo.houseplan.tech` как сервер в приложении (demo/demo), убить приложение, открыть заново на дашборде House plan.
|
||||
- HA Companion iOS — Стенд: аналогично Android.
|
||||
- Планшет-киоск — Стенд: частично: открыть вид «Киоск» браузером планшета во весь экран; реальный настенный планшет — только на железе.
|
||||
- Телефон портрет ≤400 px — Стенд: да, или DevTools device mode.
|
||||
- Тёмная/светлая тема — Стенд: профиль demo → тема; проверить бейджи/диалоги/контраст.
|
||||
- RU/EN локали — Стенд: сменить язык в профиле пользователя demo; опцию `language:` карточки можно проверить, отредактировав вид дашборда.
|
||||
|
||||
## Установка / обновление / удаление
|
||||
|
||||
- Свежая установка через HACS — Стенд: не проверяется (интеграция ставится файлами, HACS нет) — локально.
|
||||
- `single_config_entry` — Стенд: Настройки → Устройства и службы → Добавить интеграцию → «House Plan» недоступна второй раз.
|
||||
- Обновление через HACS (`?v=` после рестарта) — Стенд: не проверяется (нет HACS; версию ресурса видно в DevTools: `houseplan-card.js?v=1.54.0`).
|
||||
- YAML-mode Lovelace / extra_module_url — Стенд: частично: стенд как раз грузит карточку через `extra_module_url` (см. configuration.yaml) — если дашборд открылся, механизм жив.
|
||||
- Удаление entry → ресурс исчезает, `.storage/houseplan.*` живёт — Стенд: можно (Настройки → Интеграции → House Plan → удалить), стенд сам восстановится при сбросе; переустановку файлами проверить нельзя.
|
||||
- Diagnostics download + REDACTED — Стенд: Настройки → Интеграции → House Plan → «Загрузить диагностику», убедиться что name/link/description/pdfs = `**REDACTED**`.
|
||||
|
||||
## Режимы ★
|
||||
|
||||
- Всегда открывается во View — Стенд: войти в редактор, перезагрузить страницу — карточка снова во View.
|
||||
- View: только pan/zoom/tap/long-press, иконки не таскаются — Стенд: попробовать утащить любую иконку в режиме просмотра — должен идти pan.
|
||||
- Шапка View: табы пространств, счётчик, zoom, табы редакторов, ⚙ — Стенд: глазами в шапке «1 этаж | Двор».
|
||||
- Plan: тулбар разметки, иконки скрыты, оранжевая рамка — Стенд: вкладка «Редактор плана».
|
||||
- Devices: drag иконок, клик открывает редактор маркера — Стенд: вкладка «Редактор устройств».
|
||||
- Табы скрыты для не-админа — Стенд: не проверяется (второго, не-админского, пользователя на стенде нет).
|
||||
- Открывания во View инертны (дверь/окно — чистый рисунок) — Стенд: во View потыкать дверь в Прихожей мимо бейджа замка — ничего не происходит.
|
||||
- Бейдж замка — единственное исключение — Стенд: клик по замочку на двери открывает карточку двери; в редакторе плана он инертен.
|
||||
- Курсор pointer во View, grab в Devices — Стенд: сравнить курсор над иконкой в двух режимах.
|
||||
- В Plan открывание интерактивно (drag по стенам, 3 px порог) — Стенд: редактор плана → потаскать дверь вдоль стены, одиночный тап открывает свойства.
|
||||
|
||||
## Онбординг ★
|
||||
|
||||
Все пункты онбординга требуют ПУСТОЙ конфиг. На стенде: Настройки → Устройства и службы → House Plan можно удалить и добавить интеграцию заново, либо стереть все пространства руками — и пройти онбординг; сброс через час всё вернёт.
|
||||
- Мастер импорта этажей — Стенд: у демо-дома этажи HA не заведены, мастер предложит «Start from scratch»; полный сценарий с этажами — локально (или создать этажи в Настройки → Зоны и этажи, затем удалить пространства карточки).
|
||||
- Остальные пункты мастера (снять галки → Create disabled, очередь диалогов, Skip/Cancel, авто-разметка с тостом, пустое состояние) — Стенд: в рамках того же прогона после удаления пространств.
|
||||
|
||||
## Пространства ★
|
||||
|
||||
- Создание с картинкой (SVG/PNG/JPG/WebP), чёткость на зуме — Стенд: «+» в шапке → загрузить свой файл (демо-планы нарисованы без подложки — тем интереснее добавить).
|
||||
- >8 MB → читаемый тост — Стенд: залить большой файл.
|
||||
- «Без изображения — нарисую сам», ориентация — Стенд: «+» → режим рисования.
|
||||
- Белый холст в draw-режиме — Стенд: оба демо-пространства такие: борта/имена читаемы на белом.
|
||||
- Rename / замена картинки / image→draw отвязывает план — Стенд: ⚙ пространства.
|
||||
- Удаление пространства — Стенд: удалить «Двор», убедиться что «1 этаж» не тронут; сброс вернёт.
|
||||
- Настройки отображения: борта, имена, цвет+прозрачность, заливки — Стенд: ⚙ «1 этаж» — там всё живое, предпросмотр после сохранения.
|
||||
- Заливка «zigbee»: красный→зелёный по среднему LQI — Стенд: ⚙ «1 этаж» → заливка «Сигнал Zigbee»: Гостиная зелёная (LQI ~206), Кабинет красный (~24), Двор не залит (нет zigbee).
|
||||
- Заливка «lights» — Стенд: включить, зажечь свет в комнате с плана — комната жёлтая, потушить — серая.
|
||||
- Заливка «temperature» + границы комфорта — Стенд: включить: Спальня синяя (~18.7°), Кухня жёлтая (~26°), Гостиная зелёная (~22.8°); границы 20–25 редактируются тут же.
|
||||
- Радиогруппа заливок без легенды — Стенд: глазами в ⚙ пространства.
|
||||
- Hover затемняет текущую заливку — Стенд: навести на комнату при любой заливке.
|
||||
- Тултип комнаты со средней температурой — Стенд: навести на Кухню — «≈26°».
|
||||
- Средняя температура только от термометров — Стенд: рядом с «Тёплым полом» (Кабинет) убедиться, что его current_temperature не ломает среднее (в Кабинете свой термометр).
|
||||
- Ширина диалога 500 px, компактные поля — Стенд: глазами в ⚙ пространства.
|
||||
- Общие настройки (⚙ в шапке): цвета по режимам + Reset — Стенд: покрутить цвета glow/temp/lqi, Reset.
|
||||
- Пользовательские цвета и на статичной карточке — Стенд: сменить цвет заливки, открыть вид «Схема».
|
||||
- Градиент LQI между настроенными цветами — Стенд: сменить цвета weak/strong в ⚙ и посмотреть заливку zigbee.
|
||||
- Per-space «Показывать LQI» — Стенд: выключить в ⚙ «1 этаж» — бейджи цифр у термометров и строка сигнала в тултипах пропадают только там.
|
||||
- Центровка бейджа и глифа — Стенд: глазами на любой иконке при максимальном зуме.
|
||||
- Hover-подсветка при кастомных бортах — Стенд: покрасить борта и навести.
|
||||
- Настройки живут на сервере — Стенд: сменить цвет, открыть стенд в приватном окне/другом браузере — цвет тот же.
|
||||
|
||||
## Редактор разметки комнат ★
|
||||
|
||||
Всё проверяется в «Редакторе плана» на «1 этаже» (или создайте своё пространство — сброс простит всё).
|
||||
- Сетка, снап, общие стены — Стенд: дорисовать комнату впритык к существующей.
|
||||
- Линейка сегмента (метры; cm/cell из настроек) — Стенд: начать рисовать контур, смотреть на бегущую длину.
|
||||
- Незакрытый контур не сохраняется — Стенд: начать контур, уйти из разметки, вернуться — линий нет.
|
||||
- Удаление комнаты бережёт общие стены — Стенд: удалить Прихожую (⚙ комнаты → удалить), стены Кабинета/Спальни на месте; сброс вернёт.
|
||||
- Нет инструмента «Erase» в тулбаре — Стенд: глазами.
|
||||
- Пересечения запрещены (клик внутри чужой комнаты → тост) — Стенд: попытаться начать контур внутри Гостиной.
|
||||
- Контур вокруг существующей комнаты отклоняется — Стенд: обвести Кабинет снаружи.
|
||||
- Merge двух смежных / отказ для несмежных — Стенд: слить Кабинет+Прихожую, тост при попытке слить Кабинет+Кухню; сброс вернёт.
|
||||
- Split: две точки на стенах, бОльшая часть сохраняет имя — Стенд: разрезать Гостиную; Cancel в диалоге — комната цела.
|
||||
- Split с точкой вне стены / вдоль стены → тост — Стенд: там же.
|
||||
- Split на не-грид комнатах (снап к стене) — Стенд: комнаты демо на гриде; полноценно — локально, но снап виден: клик чуть мимо стены цепляется.
|
||||
- Промах далеко от стены → тост — Стенд: кликнуть в центр комнаты.
|
||||
- Esc/Ctrl+Z снимает точку, Reset чистит — Стенд: во время рисования.
|
||||
- Замыкание (≥4 точек) → диалог комнаты — Стенд: нарисовать комнату во «Дворе».
|
||||
- В диалоге только свободные зоны; выбор зоны подставляет имя — Стенд: все 6 зон заняты — список пуст: создать комнату «без зоны».
|
||||
- «Без зоны» требует имя, `area: null` — Стенд: там же.
|
||||
- Cancel диалога возвращает контур — Стенд: там же.
|
||||
- Сохранение с зоной раскладывает устройства зоны — Стенд: не проверяется как есть (у demo-сущностей нет device registry — автораскладка по зонам не работает, маркеры на стенде явные); полноценно — локально/на даче.
|
||||
- Erase удаляет линию, удаление комнаты — полигон — Стенд: инструментом «ластик линий» в тулбаре (клик по своей линии).
|
||||
- Иконки скрыты в разметке — Стенд: глазами.
|
||||
|
||||
## Устройства на плане ★
|
||||
|
||||
- Авто-устройства только в комнатах своей зоны — Стенд: не проверяется (см. выше: без device registry автонаполнение молчит; все маркеры стенда — явные entity-маркеры).
|
||||
- Фильтрация мостов/групп/сцен, 👁 «показать все» — Стенд: «Редактор устройств» → 👁: появляются скрытые сущности (сцены/скрипты в списке добавления отфильтрованы).
|
||||
- Нумерация дубликатов имён — Стенд: добавить второй маркер той же сущности не даст (занята) — проверяется локально.
|
||||
- Группы света сворачивают лампы — Стенд: не проверяется (в демо нет light-групп с device registry).
|
||||
- Drag иконок, снап к сетке, позиция per-space — Стенд: редактор устройств: перетащить иконку, F5 — позиция жива; у «Двора» своя раскладка.
|
||||
- ↺ сброс автолейаута — Стенд: не проверяется: кнопка Reset убрана в v1.33.2 (пункт в TESTING.md исторический).
|
||||
- Бейдж температуры, LQI с цветом — Стенд: под «Zigbee»-термометрами цифра LQI (цвет от красного к зелёному: Кабинет красный, Гостиная зелёный), температура на бейдже.
|
||||
- Живые состояния: свет жёлтый, открытая штора оранжевая, unavailable блёклый — Стенд: пощёлкать свет; открыть штору («Открыть шторы» или карточка); unavailable в демо нет — эмулировать нечем, но у робота после паузы всё живое.
|
||||
- Морфинг иконок по состоянию — Стенд: дверь/замок: открыть lock.front_door из карточки двери — иконка меняется; гаражные ворота во «Дворе».
|
||||
- «Значение вместо иконки» — Стенд: у термометра в диалоге выбрать display «значение» — на плане цифра.
|
||||
- RGB только в glow/ripple — Стенд: включить RGBWW-лампу в Гостиной цветом: бейдж жёлтый стандартный, цвет только в пятне glow.
|
||||
- Красная пульсация тревоги — Стенд: включить `input_boolean.demo_leak` (Настройки → Устройства и службы → Помощники) — датчик на Кухне пульсирует красным; дым в Спальне сам вспыхивает каждые 10 минут на 30 с.
|
||||
- Стоимость рендера (geometry once) — Стенд: не проверяется руками — smoke_render_perf.
|
||||
- Тап vs drag открывания — Стенд: редактор плана, тап по двери = свойства, drag на место = ничего не записано.
|
||||
- Вогнутый остров — Стенд: нарисовать U-комнату во «Дворе» и остров внутри.
|
||||
- Backend hardening (B2–B5) — Стенд: не проверяется руками — unit/backend.
|
||||
- Гонка сохранений (markup vs Save) — Стенд: два окна: в одном правка разметки, в другом Save диалога — правка выживает; остальное — тесты.
|
||||
- Niche split — Стенд: разрез, начинающийся и кончающийся на одной стене — вырезается ниша.
|
||||
- Контент только через подписанные URL — Стенд: залить план, скопировать URL картинки из DevTools, убрать authSig → 401; старые пути `/houseplan_files/plans` → 404.
|
||||
- Каждая опция редактора сохраняема — Стенд: перебрать display/tap/fill в диалогах — ошибок валидации быть не должно.
|
||||
- Кнопка настроек комнаты в геометрическом центре — Стенд: редактор плана, глазами.
|
||||
- Один индикатор всегда (лампа жёлтая в plan-редакторе glow-пространства) — Стенд: включить свет, открыть редактор плана — бейдж жёлтый (glow-слоя там нет).
|
||||
- Size/angle parity — Стенд: задать маркеру size 3 / angle 37 в диалоге → и на «Схеме» тоже ×3 и повёрнут.
|
||||
- Tap запускает автоматизацию — Стенд: тап по «Открыть шторы» (Гостиная) → диалог подтверждения → штора едет; в диалоге маркера видно «Run» + поиск по automation/script/scene (есть скрипт «Открыть шторы», автоматизация «Вечерний сценарий», сцены «Кино», «Вечер в гостиной»); Esc = не выполнять.
|
||||
- Бейджи light-source в glow — Стенд: заливка glow (по умолчанию): у горящей лампы бейдж стандартный (индикатор — пятно), у розетки-«источника света» жёлтый.
|
||||
- Множитель размера иконки растит глиф — Стенд: size 3 у любого маркера.
|
||||
- Auto-grid parity, ripple ghost, hidden LQI parity, ghost без цифр — Стенд: скрыть «Zigbee»-термометр: LQI комнаты не меняется, «Схема» совпадает с планом; «Show hidden» в редакторе устройств показывает синий пунктирный призрак без значений.
|
||||
- Флаг «скрыть с плана» — Стенд: галка в любом диалоге устройства; скрытый исчезает из всех режимов и счётчика, но LQI комнаты держит.
|
||||
- «Жёлтый = работает» — Стенд: «Тёплый пол» в Кабинете: hvac_action=heating → жёлтая подложка. (Выключить нагрев можно, поставив target ниже 20° в карточке — бейдж гаснет.)
|
||||
- Жесты редактора на touch — Стенд: с телефона в редакторе плана: pinch = zoom, палец = pan, отпускание не ставит точку.
|
||||
- Legacy-геометрия, положительные размеры, границы 1e100, no-op repair — Стенд: не проверяется (нужен подложенный битый store, у тестировщика нет доступа к .storage) — unit/backend.
|
||||
- Карточка под другим контентом дашборда — Стенд: вид «Схема» — карточка стоит после других карточек и не схлопывается.
|
||||
- Stranded migration repair / geometry repair — Стенд: не проверяется руками (WS-команда) — backend.
|
||||
- Редакторы видят весь холст — Стенд: «Двор» (одна комната): в редакторе плана виден весь белый квадрат, во View — контент-фит.
|
||||
- Save ждёт пропорции выбранного плана — Стенд: прикрепить «уже загруженный» план и мгновенно Save.
|
||||
- Зум ниже фита (пол 0.4x) — Стенд: минусовать зум ниже 100%.
|
||||
- Migration crash / parallel upload quota — Стенд: не проверяется (kill HA между записями невозможен) — backend.
|
||||
- Зум открывается на контенте — Стенд: «Двор» открывается комнатой на весь экран.
|
||||
- Удаление используемого плана запрещено — Стенд: залить план, в списке «уже загруженные» его delete неактивен.
|
||||
- Квота загрузок — Стенд: можно доливать планы до квоты — тост; сброс почистит.
|
||||
- Миграция на квадратный холст — Стенд: не проверяется (миграция уже прошла; нужен старый store) — unit.
|
||||
- Картинка плана по центру с полями — Стенд: залить широкую картинку — поля сверху/снизу, без растяжения.
|
||||
- Re-attach отвязанного плана — Стенд: ⚙ пространства → «рисование» → Save → reload → «Уже загруженные» → прикрепить обратно.
|
||||
- Detach хранит файл — Стенд: там же: после отвязки файл в списке; замена плана убирает старый сразу.
|
||||
- Перепривязка не ест мануалы — Стенд: маркер с 2 PDF перепривязать на другую сущность — оба открываются.
|
||||
- Ничего не копится на idle — Стенд: не проверяется полноценно (ежечасный сброс раньше суточного sweep) — backend.
|
||||
- Drag выигрывает у удалённого move — Стенд: два окна, тащить разные иконки одновременно.
|
||||
- Одноимённые загрузки из двух вкладок — Стенд: можно, два вложения обязаны выжить; тонкости — unit.
|
||||
- Временные файлы не выживают — Стенд: не проверяется (нужен доступ к ФС) — backend.
|
||||
- Две полные карточки согласны в позициях — Стенд: два окна: drag в одном виден во втором без reload.
|
||||
- Загруженный SVG инертен (script не исполняется) — Стенд: залить SVG со `<script>alert(1)</script>`, открыть его подписанный URL в новой вкладке — alert нет, план рендерится.
|
||||
- Вложение не перезаписывает другое — Стенд: приложить manual.pdf к двум маркерам — два независимых файла.
|
||||
- Два быстрых эдита выживают — Стенд: быстро сделать две правки подряд (throttle сети в DevTools) — обе на месте.
|
||||
- Открытые границы следуют геометрии — Стенд: открыть границу (инструмент «Открыть границу») между Гостиной и Кухней, потом утащить вершину стены — разрыв едет вместе.
|
||||
- Внутренние лимиты (max+1) — Стенд: не проверяется руками — unit/backend.
|
||||
- Большие файлы стримятся (50 MB) — Стенд: можно залить 50 MB мануал и скачать дважды; память HA снаружи не видна — полноценно только локально.
|
||||
- Паритет статичной карточки (fill none) — Стенд: у комнаты поставить fill «нет» и сравнить со «Схемой».
|
||||
- Layout доходит до статичной карточки — Стенд: drag иконки на «Плане» → на «Схеме» (другая вкладка того же дашборда) двигается.
|
||||
- Repair-issue смертны — Стенд: удалить пространство с битым планом… проще: удалить пространство — связанные предупреждения из Настройки → Ремонт исчезают.
|
||||
- Не-подписанный путь не зацикливает запросы — Стенд: не проверяется руками — unit.
|
||||
- Подписи не усиливаются на плохой сети — Стенд: DevTools throttling + Network: один sign-запрос на URL.
|
||||
- Битая папка планов не валит save — Стенд: не проверяется (нужна ФС) — backend.
|
||||
- Два редактора, один план — Стенд: две вкладки, залить подложку в каждой по очереди — служится последняя, файлы целы.
|
||||
- Фон статичной карточки — Стенд: залить план «1 этажу», открыть «Схему»: картинка есть, в Network нет неподписанных запросов.
|
||||
- Отклонённый save не трогает план — Стенд: две вкладки: обе меняют план, вторая получает конфликт — старый план жив.
|
||||
- Кэш подписей на 200+ URL — Стенд: не воспроизводимо (нет 200 файлов) — smoke_sign_cap.
|
||||
- Климат не дорожает с комнатами — Стенд: не проверяется руками — smoke_climate_once.
|
||||
- Upload при конкурентной ревизии — Стенд: две вкладки: приложить фон при открытом втором редакторе.
|
||||
- Подписанный фон плана — Стенд: см. выше: authSig в Network, нет failed login в журнале HA (Настройки → Система → Журналы).
|
||||
- Зомби-диалоги — Стенд: Esc во время сохранения при throttling — диалог закрыт, тост об ошибке живёт.
|
||||
- Нет hover-тултипов на touch — Стенд: с телефона.
|
||||
- Слайдеры шрифтов (50–300%) — Стенд: ⚙ пространства + ⚙ комнаты: живой образец в диалоге следует слайдеру.
|
||||
- Room settings tier 3 (имя, зона, fill override, источник T°/влажности) — Стенд: ⚙ любой комнаты; источник температуры прописан у комнат демо через зонные термометры — переопределить и посмотреть карточку.
|
||||
- PDF переживает перепривязку — Стенд: см. «перепривязка не ест мануалы».
|
||||
- Киоск-режим — Стенд: вид «Киоск» дашборда: шапки нет, свайп листает пространства с точками-индикатором, дабл-тап сброс зума, лонг-пресс 3 с на пустом месте — поповер размеров; авто-листание cycle — задано в конфиге вида; реальный планшет — руками на своём.
|
||||
- Иконка-ссылка зоны у имени комнаты — Стенд: во View у имён комнат есть ↗ (зоны привязаны) — клик ведёт в зону HA; в редакторах иконки нет.
|
||||
- Смарт-гайды — Стенд: тащить иконку в редакторе устройств — пунктирные направляющие от соседей, бейдж длина·угол.
|
||||
- Свет тогглится по умолчанию — Стенд: тап по любой лампе во View = вкл/выкл без настройки.
|
||||
- Производные стены тоже режутся (.seg) — Стенд: открыть границу и смотреть в редакторе плана: сплошной линии под пунктиром нет.
|
||||
- Пунктир открытых границ в разметке — Стенд: там же.
|
||||
- Nav-персистентность (пространство+режим) — Стенд: уйти в «Двор», закрыть вкладку, открыть — снова «Двор»; `#space=floor1` в URL сильнее.
|
||||
- Чистка tap-действий + правый клик — Стенд: ПКМ по иконке во View = more-info HA.
|
||||
- Редизайн секции биндинга — Стенд: «+» в редакторе устройств: радио Виртуальное/Из списка HA, «показать сущности», поиск в дропдауне.
|
||||
- Настоящий пунктир открытой границы — Стенд: см. открытые границы.
|
||||
- Hover открытия стены — Стенд: инструмент «Открыть границу»: у общей стены курсор pointer + янтарный пунктир-превью; на уже открытой — красное превью.
|
||||
- Открытые границы: свет течёт по зоне — Стенд: открыть границу Гостиная-Кухня при glow: свет горящей лампы заливает обе.
|
||||
- Секторы дверей не темнят комнату — Стенд: glow: открыть дверь Прихожей, смотреть сектор света.
|
||||
- Per-source радиус glow — Стенд: поле «Радиус свечения» в диалоге лампы.
|
||||
- Скрытая light-primary — Стенд: не проверяется (нет скрытых-в-реестре ламп в демо) — локально.
|
||||
- Marker controls (тогл связки ламп) — Стенд: у «Котла»/виртуального маркера прописать «Управляет источниками света» + Toggle и тапнуть: обе лампы Гостиной разом.
|
||||
- Glow fill в целом — Стенд: заливка по умолчанию на «1 этаже»: зажечь RGBWW-лампу цветом — цветное пятно, радиус в ⚙.
|
||||
- Островные комнаты — Стенд: нарисовать остров во «Дворе».
|
||||
- Иконка не прыгает при перепривязке — Стенд: перепривязать маркер: позиция та же; смена комнаты в том же пространстве тоже не двигает.
|
||||
- Плейсхолдер автоиконки — Стенд: диалог без явной иконки: «Auto: mdi:…» с превью.
|
||||
- Нет кнопки Reset в тулбаре Devices — Стенд: глазами: add / 👁 / ⬡.
|
||||
- Сетка во всех редакторах + fade декора — Стенд: редактор фона: комнаты/иконки блёкнут до 35%, декор яркий.
|
||||
- Редактор фона (фигуры/текст/ластик) — Стенд: порисовать во «Дворе»: прямоугольник-«газон», текст.
|
||||
- Превью открывания (призрак 90 см) — Стенд: инструмент «Открывание», водить у стены.
|
||||
- Split-polyline, курсоры, Esc-цепочка — Стенд: split Гостиной ломаной через середину.
|
||||
- Подсветка выбранного в merge/split — Стенд: янтарная обводка первой комнаты.
|
||||
- Карточка vs инструмент — Стенд: в редакторе плана тащить карточку комнаты при активном Draw — точек не ставится.
|
||||
- Карточки комнат (метрики, drag, resize) — Стенд: включить метрики в ⚙ пространства; в редакторе плана карточку можно тащить и растягивать за углы.
|
||||
- Esc закрывает диалоги по одному — Стенд: наоткрывать вложенных диалогов.
|
||||
- Общая ⚙ видна во всех режимах — Стенд: глазами.
|
||||
- Табы редакторов (2 шт + X) — Стенд: глазами.
|
||||
- ⚙ пространства в каждом режиме — Стенд: глазами.
|
||||
- Lock action (Unlock красный + подтверждение) — Стенд: карточка двери в Прихожей.
|
||||
- Флаг нового устройства (красная точка) — Стенд: включить незанятую сущность… проще: не проверяется напрямую (набор сущностей демо фиксирован; точка была бы у новых) — локально.
|
||||
- Ноль устройств в HA — Стенд: не проверяется (в демо 100+ сущностей) — smoke_new_device.
|
||||
|
||||
## Диалог устройства (маркеры) ★
|
||||
|
||||
- Все поля сохраняются — Стенд: любой маркер: имя/иконка/модель/ссылка/описание, F5.
|
||||
- Перепривязка с поиском, занятые исключены — Стенд: дропдаун биндинга: уже размещённые сущности в списке отсутствуют.
|
||||
- Виртуальное устройство: имя+комната, пунктир — Стенд: «Котёл» — образец; создать своё.
|
||||
- Комнаты без зоны в списке комнат — Стенд: нарисовать комнату «без зоны» и поместить туда маркер.
|
||||
- Room override центрирует — Стенд: сменить комнату маркера.
|
||||
- Tap-action override — Стенд: перебрать все варианты у одного маркера.
|
||||
- PDF: ok / >50 MB / .exe — Стенд: приложить файлы к маркеру; большие и .exe должны дать читаемую ошибку.
|
||||
- `javascript:` в ссылке не кликабелен — Стенд: вписать `javascript:alert(1)` в поле ссылки — в карточке не ссылка.
|
||||
- Remove: авто → скрытый маркер, виртуальное → насовсем — Стенд: удалить «Котёл» (вернётся сбросом) и любой entity-маркер.
|
||||
|
||||
## Правила иконок ★
|
||||
|
||||
- ⬡ открывает редактор с текущими правилами — Стенд: редактор устройств → ⬡.
|
||||
- Тест-поле, добавление/удаление/порядок — Стенд: там же, ввести имя «Термометр…».
|
||||
- Битый regex краснеет и пропускается — Стенд: вписать `([`.
|
||||
- Reset к дефолтам — Стенд: там же.
|
||||
- Правила переиконивают существующие; per-device иконка сильнее; замки всегда mdi:lock — Стенд: правило `.*свет.* → mdi:star` и смотреть план.
|
||||
- Живут после reload и во втором браузере — Стенд: приватное окно.
|
||||
|
||||
## Tap-действия и жесты ★
|
||||
|
||||
- Тап → карточка, `toggle` тогглит только свет/розетки/вентиляторы — Стенд: пощёлкать лампы (toggle по умолчанию), медиаплеер (карточка).
|
||||
- Замки не тогглятся никогда; шторы только с явным override — Стенд: замок двери; штора Гостиной: выставить «Toggle» в диалоге — начнёт тогглиться, гаражные ворота — нет.
|
||||
- Long-press всегда карточка — Стенд: зажать лампу на 600 мс — карточка вместо toggle.
|
||||
- Drag >3 px отменяет tap/long-press — Стенд: потащить палец с иконки.
|
||||
- pointercancel без фантомов — Стенд: с телефона: начать тап и свернуть браузер.
|
||||
|
||||
## Zoom / pan / подписи
|
||||
|
||||
- Wheel у курсора, +/−, fit, % — Стенд: глазами.
|
||||
- Pinch + двухпальцевый pan — Стенд: с телефона.
|
||||
- Зум живёт per-space в localStorage — Стенд: зазумить «1 этаж», перейти в «Двор» и назад, F5.
|
||||
- Resize окна перефитит — Стенд: свернуть боковую панель HA.
|
||||
- Подписи комнат таскаются (rl_*) — Стенд: в редакторе плана утащить имя комнаты, F5.
|
||||
- Читаемость подписей на мин/макс зуме — Стенд: глазами на белом плане.
|
||||
|
||||
## Мультиклиент ★
|
||||
|
||||
- Две вкладки: drag виден без reload — Стенд: два окна рядом.
|
||||
- Конфликт конфига → тост, авторесинк, retry — Стенд: два окна: одновременно править настройки пространства.
|
||||
- Точечные layout-апдейты не трутся — Стенд: тащить разные иконки в двух окнах.
|
||||
- `admin_only` для не-админа — Стенд: не проверяется (нет второго пользователя) — backend.
|
||||
|
||||
## Крайние случаи
|
||||
|
||||
- HA без устройств/зон — Стенд: не проверяется (демо полон сущностей) — локально.
|
||||
- Пространство без комнат — Стенд: создать пустое, видна подсказка разметки.
|
||||
- Комната без зоны + борта — Стенд: нарисовать, кликнуть — ничего.
|
||||
- Нет zigbee вовсе — Стенд: «Двор»: заливка lqi оставляет двор незалитым, бейджей нет.
|
||||
- 100+ устройств в одном пространстве — Стенд: не проверяется (в «1 этаже» ~30 маркеров) — локальная синтетика.
|
||||
- Длинные имена — Стенд: переименовать маркер в очень длинное имя.
|
||||
- HTML/эмодзи в именах — Стенд: назвать маркер `<b>xss</b>🚿` — текст, не разметка.
|
||||
- План удалён с диска → Repairs — Стенд: не проверяется (нет доступа к ФС) — backend.
|
||||
- Битый houseplan.config — Стенд: не проверяется (нет доступа к .storage) — backend.
|
||||
- Рестарт HA при открытом диалоге — Стенд: Настройки → Система → Перезапустить (админ может!), диалог держать открытым; после рестарта save даст чистую ошибку. Помнить: рестарт контейнера = сброс, но сам HA-рестарт изнутри сброс не триггерит.
|
||||
- Legacy layout v1 — Стенд: не проверяется — unit.
|
||||
- Киоск холодный старт в приложении — Стенд: Companion app + вид «Киоск», убить и открыть.
|
||||
|
||||
## Релизные регрессии
|
||||
|
||||
- Ноль ошибок консоли — Стенд: DevTools console на дашборде, в разметке, диалогах, зуме.
|
||||
- Ноль houseplan-ошибок в логе HA — Стенд: Настройки → Система → Журналы, фильтр «houseplan».
|
||||
- npm test / pytest / CI — Стенд: не про стенд — CI.
|
||||
- Скриншоты README — Стенд: не про стенд.
|
||||
|
||||
## houseplan-space-card (вид «Схема»)
|
||||
|
||||
- Рендер идентичен плану, без шапки — Стенд: вид «Схема» дашборда.
|
||||
- Полностью неинтерактивна — Стенд: покликать по «Схеме» — ничего, даже тултипов.
|
||||
- Кнопка-футер открывает полную карточку на этом пространстве — Стенд: клик по кнопке под «Схемой».
|
||||
- Несколько с разными space — Стенд: «Схема» содержит обе (1 этаж и Двор) — один общий WS-запрос (Network).
|
||||
- Неизвестный space → аккуратная ошибка — Стенд: отредактировать вид, вписать space: nope.
|
||||
- show_button: false — Стенд: правкой YAML вида.
|
||||
- #space= диплинк — Стенд: открыть `…/plan#space=yard`.
|
||||
|
||||
## Presence ripples / per-device иконка
|
||||
|
||||
- «Только пульсация»: бейдж исчезает, кольца при on — Стенд: у датчика движения «Movement Backyard» (Кухня) поставить display «пульсация» — он периодически on/off сам.
|
||||
- «Иконка + пульсация» — Стенд: там же.
|
||||
- Цвет и размер ×2..×8 — Стенд: там же.
|
||||
- unavailable останавливает пульс — Стенд: не проверяется (нет unavailable-сущностей в демо) — smoke.
|
||||
- Размер ×0.5..×3 и поворот; бейджи масштабируются — Стенд: любой маркер.
|
||||
- Риплы при выключенных live-состояниях — Стенд: выключить «живые состояния» в конфиге карточки (правка вида) — риплы живут.
|
||||
- reduce motion — Стенд: включить в ОС тестера.
|
||||
|
||||
## Двери и окна
|
||||
|
||||
Дверь с замком (Прихожая, lock.front_door) и окно с датчиком (Кухня, contact = «Basement Floor Wet») уже стоят.
|
||||
- Клик мимо стены → тост; у стены → диалог — Стенд: инструмент «Открывание» в разметке.
|
||||
- Дверь: косяки+полотно+дуга, длина в см — Стенд: глазами у входной двери.
|
||||
- Контакт: open → створка + дуга акцентом — Стенд: датчик окна Кухни переключается сам (это «мокрый пол» из демо — он мигает); либо повесить contact на `binary_sensor.demo_leak` и дёргать помощник.
|
||||
- Unavailable → статичный дефолт — Стенд: не проверяется (нет unavailable) — локально.
|
||||
- Замок: зелёный/оранжевый/серый бейдж — Стенд: замок двери, открыть/закрыть из карточки.
|
||||
- Карточка с двумя состояниями, замок не тогглится с плана — Стенд: клик по двери/замку во View.
|
||||
- Flip зеркалит петли/сторону — Стенд: диалог открывания (двойной клик с инструментом).
|
||||
- Клик с инструментом → редактирование; Delete — Стенд: там же.
|
||||
- Hover: акцент + grab во View — Стенд: навести на дверь.
|
||||
- Drag вдоль стен с переснапом за углы — Стенд: тащить дверь по периметру Прихожей.
|
||||
- Один клик = карточка, дабл = свойства, drag = ничего — Стенд: там же.
|
||||
|
||||
## Живые пылесосы (docs/VACUUM.md)
|
||||
|
||||
Симулятор: «Робот-пылесос» в Прихожей (база = маркер), карта `sensor.demo_robot_map`
|
||||
в стиле Tasshack (позиция-словарь, комнаты с именами как на плане, mm, ось Y
|
||||
перевёрнута), калибровка уже прописана. Полный цикл уборки ~3.5 минуты.
|
||||
- Docked: только маркер базы — Стенд: глазами (робот в Прихожей).
|
||||
- Уборка: круглая пульсирующая шайба едет, база на месте — Стенд: карточка робота → Start (или Настройки → Устройства и службы → Сущности → vacuum.demo_robot); шайба змейкой объезжает все 5 комнат.
|
||||
- Глайд ~1.2 с, телепорт при zoom/pan/switch — Стенд: во время уборки позумить.
|
||||
- Режимы следа never/cleaning/always — Стенд: диалог маркера робота → «Показывать путь робота»; в always виден след прошлой уборки (40%) — он уже записан в эталоне.
|
||||
- Серверный след (две уборки, переживает reload) — Стенд: запустить уборку, F5 посреди — линия продолжается с места; вторая вкладка видит ту же линию.
|
||||
- Стиль: тёмный ореол + светлое ядро — Стенд: глазами поверх glow-заливки.
|
||||
- Скрытый маркер: ни шайбы, ни следа — Стенд: скрыть маркер робота на время уборки.
|
||||
- Калибровка: «Настроить автоматически» — Стенд: диалог робота → «Живая позиция»: имена комнат карты совпадают с планом (все 5) — автокалибровка сходится; панель подгонки (призрак, углы, поворот, зеркало — зеркало осмысленно: ось Y в карте перевёрнута) тоже можно открыть и покрутить, Esc/Cancel не портит.
|
||||
- Мультиэтаж (матрица на карту) — Стенд: частично: карта одна (map 1), матрица хранится по её id; мультикарта — только на реальном Dreame.
|
||||
- Pause/Return — Стенд: кнопки в карточке робота: пауза замирает, «домой» возвращает по своим следам с двойной скоростью.
|
||||
|
||||
## Чего на стенде нет принципиально
|
||||
|
||||
- Реальное Zigbee/Z-Wave железо, реальные роботы (Dreame/Xiaomi/Valetudo), реальный настенный планшет.
|
||||
- HACS-путь установки/обновления, YAML-mode Lovelace, переустановка интеграции с сохранением конфига.
|
||||
- Всё, что требует доступа к файловой системе стенда: битые store, kill -9 между записями, недоступные папки, наблюдение за памятью процесса.
|
||||
- Долгоживущие сценарии (суточный sweep, «часом позже») — сброс стенда наступает раньше.
|
||||
- Не-админский пользователь (admin_only, скрытие табов) — на стенде один админ demo.
|
||||
@@ -252,6 +252,15 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
[auto: smoke_light_badges]
|
||||
- [ ] Size/angle parity (v1.52.1, HP-1513-01): a marker with size 3 / angle 37
|
||||
scales x3 and rotates on BOTH cards [auto: smoke_size_angle_parity]
|
||||
- [ ] Tap runs an automation (dev, owner's spec 2026-07-29): the tap-action
|
||||
list has "Run automation/script/scene" with a searchable picker; saving
|
||||
without a target is refused; the confirm checkbox guards toggle AND run
|
||||
(our dialog, Esc/cancel = no call); automation.trigger / script.turn_on /
|
||||
scene.turn_on per domain; a deleted target toasts and calls nothing;
|
||||
covers/valves joined the card-wide toggle EXCEPT garage/door/gate
|
||||
device classes (explicit per-device toggle still works for them)
|
||||
[auto: smoke_tap_run + unit resolveTapAction/runServiceFor + backend
|
||||
test_run_target_is_bounded_to_runnable_domains]
|
||||
- [ ] Light-source badges (v1.52.0): in glow fill a lit lamp's badge stays
|
||||
standard (the spot is the indicator) and a lit socket stays yellow; in
|
||||
other fills a lit lamp is plain yellow with no RGB tint; morphing and
|
||||
@@ -798,3 +807,62 @@ require hands on real hardware — they remain for the human pass.
|
||||
crossing wall-segment boundaries
|
||||
- [ ] Single click still opens the status card; double click opens the properties dialog;
|
||||
a drag does NOT open either
|
||||
|
||||
## Live vacuums (docs/VACUUM.md)
|
||||
|
||||
- Docked robot: only the base marker, at the user-placed spot (the dock).
|
||||
- Cleaning + calibrated map: a round pulsing puck — the base badge but
|
||||
circular and 20% smaller, same plate colors, glyph dead-centre — drives
|
||||
the plan; the base marker never moves. No heading arrow.
|
||||
- Puck motion: glides ~1.2 s between telemetry points; TELEPORTS (no glide)
|
||||
on zoom, pan, space switch, browser-tab return, and after a >10 s data
|
||||
gap. Stale coords (>60 s while cleaning) freeze and dim it.
|
||||
- «Показывать путь робота» has three modes: never / while cleaning (default
|
||||
— the line hides the instant the run ends) / always (the only mode that
|
||||
also shows the previous run at 40% opacity).
|
||||
- The trail is server-recorded (trails.py watches the source entity; two
|
||||
runs kept per marker, survives reloads, shared by every screen) and never
|
||||
outruns the icon: drawn segments lag one point, and the last segment is a
|
||||
rAF-driven tip glued to the puck centre every frame.
|
||||
- Trail style: cartography casing (dark halo 2.25 + light core 0.9),
|
||||
readable over any room fill.
|
||||
- Hidden marker: neither puck nor trail. Uncalibrated active map: no puck.
|
||||
- Calibration: «Настроить автоматически» (≥3 rooms matched by name) or the
|
||||
fit panel — drag the dashed room ghost, stretch by 4 corner handles,
|
||||
rotate 90°/mirror buttons (mirror ON by default), Save/Cancel/Esc. Real
|
||||
clicks must land on the overlay (elementFromPoint smoke guards it).
|
||||
- Multi-floor: one matrix per robot map (Dreame `selected_map` on the
|
||||
vacuum entity names the active one).
|
||||
|
||||
## Room resize (docs/RESIZE.md)
|
||||
|
||||
- [ ] The «Размер» tool appears in the Plan editor toolbar; in EVERY other
|
||||
tool (and in Devices/Decor/View) there is not a single `.rszhandle`
|
||||
- [ ] Handles sit at the midpoint of every wall of every room; they are
|
||||
finger-sized, capture the pointer and never start a stage pan
|
||||
- [ ] Dragging a handle moves the wall along its normal, both edge ends
|
||||
together; the wall line snaps to the drawing grid
|
||||
- [ ] A wall fully shared with a neighbour drags the neighbour's wall with
|
||||
it — the neighbour shrinks/grows, no gap and no overlap can appear
|
||||
- [ ] A partially shared wall (T-junction) moves only the coinciding
|
||||
stretch: the neighbour becomes L-shaped (new vertices), shown live
|
||||
- [ ] Legacy x/y/w/h rectangle rooms resize the same way and are saved back
|
||||
as polygons
|
||||
- [ ] Live badges while dragging: lengths of the dragged wall + both
|
||||
adjacent walls, and the m² area at the room centre; dragging a shared
|
||||
wall shows BOTH areas; all numbers update continuously
|
||||
- [ ] Stops: ~30 cm minimum for the own room AND the shrinking neighbour;
|
||||
a foreign room in the path (touch is ok, overlap never); islands
|
||||
inside; a door/window on a shortening wall (the wall corner can not
|
||||
pass the opening edge)
|
||||
- [ ] A door/window ON the moving wall travels with it (openings x/y
|
||||
recompute; angle unchanged)
|
||||
- [ ] Esc mid-drag cancels: the original geometry is back instantly
|
||||
- [ ] Click a room in the resize tool → dashed bbox frame with 4 corner
|
||||
handles; dragging a corner scales all vertices proportionally about
|
||||
the opposite corner; neighbours are NOT dragged along (the one
|
||||
exception to «shared walls together»), growing into one stops
|
||||
- [ ] Ctrl+Z / ⌘Z after releasing a handle restores the previous geometry —
|
||||
one release = one undo step (rooms AND openings)
|
||||
- [ ] Device markers do not move; the room settings gear re-centres itself
|
||||
- [ ] Smoke: `node demo/smoke_room_resize.mjs`
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
# Live robot vacuums on the plan — the spec (source of truth)
|
||||
|
||||
Status: approved by the owner 2026-07-31. Scope decisions final: all
|
||||
three Tier-A adapters in P1, the trail ships in P1, the marker's placed
|
||||
position IS the dock, and tap-to-clean is out — display only, no
|
||||
commands (owner: «не нужно вообще»).
|
||||
|
||||
## Principle
|
||||
|
||||
The device marker «Пылесос» NEVER moves: it stands where the user
|
||||
placed it — that is the base/dock — with its normal badge, states and
|
||||
tap actions. While the robot cleans, a SECOND visual appears: a round
|
||||
puck (the vacuum icon in a circle), no badge plate, with a soft
|
||||
pulse, driving around the plan on live coordinates. Owner's exact
|
||||
wording: «Иконка движущегося пылесоса должна быть круглой, без
|
||||
подложки и с легкой пульсацией. Основное устройство "Пылесос" при этом
|
||||
остается всегда на своем месте (база)». The puck is not clickable UI
|
||||
chrome duplicating the device — a tap on it opens the same more-info as
|
||||
the base marker. When the robot docks, the puck drives home and
|
||||
dissolves into the base marker.
|
||||
|
||||
## Data tiers (auto-detected, zero user input)
|
||||
|
||||
- **Tier A — coordinates + own calibration data.** `vacuum_position
|
||||
{x,y,angle}` (+ `calibration_points`, rooms, sometimes `path`):
|
||||
Xiaomi Cloud Map Extractor, dreame-vacuum (Tasshack), Valetudo camera
|
||||
(sca075). All three ship in P1.
|
||||
- **Tier B — bare coordinates.** Roomba core (`position {x,y,theta}`),
|
||||
raw Valetudo MQTT, template sensors. Works after manual calibration.
|
||||
P3.
|
||||
- **Tier C — current room only.** `current_room` / `current_segment`.
|
||||
The room being cleaned gets a soft fill pulse + a 🤖 badge in the
|
||||
room card; no puck. Segment→room matched by name, manual override in
|
||||
the dialog.
|
||||
- **Tier D — nothing.** Today's behaviour; yellow badge on the base
|
||||
marker while cleaning per the «yellow = working right now» principle.
|
||||
|
||||
Tiers degrade gracefully: coords gone → room highlight; that gone too →
|
||||
just the base marker.
|
||||
|
||||
## Coordinate binding
|
||||
|
||||
Affine transform (translate+rotate+scale+mirror), 6 numbers, least
|
||||
squares over ≥3 point pairs. Stored per robot map:
|
||||
`marker.vacuum.calibration[map_id]` — multi-floor robots get one matrix
|
||||
per map; an active map without a matrix falls back to Tier C.
|
||||
|
||||
- **Path 1, auto-calibration by rooms (the default):** Tier-A adapters
|
||||
expose the robot's room list with coordinates; our rooms carry HA
|
||||
area bindings. Match by name, take centroids of ≥3 matches, solve,
|
||||
show a live preview («робот сейчас здесь») — one click to confirm.
|
||||
- **Path 2, the fit panel (replaced the 3-point wizard, owner call
|
||||
2026-07-31):** the robot's rooms render as a translucent dashed ghost
|
||||
over the plan; the user DRAGS the ghost into place and stretches it by
|
||||
its four corner handles (uniform scale about the opposite corner, like
|
||||
a graphics-editor frame). Rotation is quarter-turn buttons, mirror is
|
||||
one toggle — both re-anchor about the ghost centre. Mirror defaults ON:
|
||||
every robot map seen so far has Y flipped versus the screen. No numeric
|
||||
fields; the old park-the-robot-three-times wizard is gone entirely —
|
||||
it was the most fragile part of the feature.
|
||||
- The panel folds into the same stored 6-number matrix
|
||||
(S·R(rot)·mirror + offset); legacy matrices reopen in the panel with
|
||||
the rotation snapped to the nearest quarter.
|
||||
|
||||
## Puck behaviour
|
||||
|
||||
Appears when the robot leaves `docked` AND live coords flow; CSS
|
||||
interpolation ~1.2 s between updates; a data gap over 10 s teleports
|
||||
without animation (no gliding through walls on sparse cloud updates).
|
||||
Soft pulse always while visible (respects prefers-reduced-motion: the
|
||||
pulse freezes, position still animates). Size: same --icon-size
|
||||
system as devices, circle, transparent background, no badge plate.
|
||||
Stale coords (>60 s while «cleaning»): puck freezes and dims. On
|
||||
`docked`/`idle`-at-base the puck rides home and fades out. Hidden
|
||||
markers render neither base nor puck (general hidden rules). Works in
|
||||
the full card, static card and kiosk; in editors no puck — the base
|
||||
marker is edited as usual.
|
||||
|
||||
## Trail (ships in P1)
|
||||
|
||||
- Integration `path` when available (Map Extractor) — transform and
|
||||
draw as-is, includes history from before the card was opened.
|
||||
- Otherwise self-recorded: client-side ring buffer, ~600 points with
|
||||
Douglas-Peucker thinning, one SVG polyline.
|
||||
- Style fixed, no options: cartography casing — a dark translucent halo under a light core. Neutral (pure black/white alphas) and readable over any room fill; blend modes were rejected: each has a blind luminance where the line vanishes, and mix-blend-mode is costly on old kiosk WebViews. Lifecycle:
|
||||
appears on cleaning start, lives until dock + 10 min, dissolves;
|
||||
`docked → cleaning` clears the old trail. Recorded SERVER-SIDE by the integration itself (trails.py): it watches the source entity, so the path records with zero cards open and every screen sees the same line. Stored per marker: the current run and ONE previous run (owner call — users compare cleaned vs uncleaned). The previous run renders at 40% opacity even at rest; the current run trims its live tail while moving. Rotation on run start or map switch; 2000-point cap with decimation; store writes debounced 10 s; houseplan_trail_updated notifies live cards.
|
||||
- Marker option «Показывать след уборки», on by default where data
|
||||
exists.
|
||||
|
||||
## Setup UX
|
||||
|
||||
A «Живая позиция» section in the device dialog, vacuum markers only:
|
||||
status line (which source was found / nothing), the «Настроить
|
||||
автоматически» button (Tier A — the integration reports a room list),
|
||||
the «Подогнать вручную» button opening the fit panel (drag the ghost
|
||||
map, stretch by the corners, rotate/mirror), one «Живая позиция на
|
||||
плане» checkbox (on by default), the «Показывать путь робота» select
|
||||
(never / while cleaning / always), and for multi-map robots a list of
|
||||
calibrated maps. The source entity is discovered via the device
|
||||
registry — no YAML, no entity pickers. The section works before the
|
||||
marker is ever saved: the first vacuum edit materialises the marker
|
||||
itself (HP-1540-01).
|
||||
|
||||
## Storage & validation
|
||||
|
||||
`marker.vacuum: { live, trail, room_highlight, source, calibration:
|
||||
{[map_id]: [6 numbers]}, segment_map: {[segment]: room_id} }` — all
|
||||
optional (old configs stay valid), matrices are 6 finite numbers,
|
||||
backend-validated. Calibration is server-side state shared by every
|
||||
screen, like the whole plan.
|
||||
|
||||
## Cases covered explicitly
|
||||
|
||||
Two vacuums (independent markers, calibrations, pucks); one robot on
|
||||
two floors (maps ↔ spaces, the puck only renders in the space whose map
|
||||
is active); robot outside the plan (±4 canvas bounds allow it, trail
|
||||
clipped by viewport); integration restart changes map_id (rebind by
|
||||
room-list match, else ask to recalibrate); user redraws rooms (the
|
||||
matrix does not depend on rooms); demo stand gets a scripted synthetic
|
||||
robot as the showcase.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Commands of any kind (owner decision: display only) — no tap-to-clean,
|
||||
no zone sending. No-go zones, cleaned-area polygons, cleaning history,
|
||||
multi-robot collision avoidance — v2 candidates.
|
||||
|
||||
## Trail display modes
|
||||
|
||||
`marker.vacuum.trail_mode`: `never` | `cleaning` (default — the line hides
|
||||
the instant the run ends) | `always` (the only mode that also draws the
|
||||
previous run, at 40% opacity). The legacy boolean `trail` still maps in
|
||||
(`false` → never). Recording is independent of the mode: the server always
|
||||
records, the mode only decides what is drawn.
|
||||
|
||||
The last segment is a rAF-driven tip line whose endpoint is glued to the
|
||||
puck's animated centre every frame, so the path pours out from under the
|
||||
icon instead of popping in when the next telemetry point lands.
|
||||
|
||||
## Phases
|
||||
|
||||
- **P1:** adapter framework + all three Tier-A adapters +
|
||||
auto-calibration by rooms + manual 3-point wizard + the puck + trail (both sources).
|
||||
- **P2 (next):** Tier C room highlight + the demo-stand scripted robot.
|
||||
- **P3:** Tier B zoo (Roomba, raw Valetudo MQTT), Deebot, multi-map
|
||||
polish by feedback.
|
||||
|
||||
## Shipped in P1
|
||||
|
||||
Adapters for the three Tier-A integrations, auto-calibration by room
|
||||
names, the drag-and-stretch fit panel, the puck, server-side trails
|
||||
(current + previous run) with the three display modes. Verified against a
|
||||
live Dreame X50 Master: room centres arrive as plain x/y, the active map
|
||||
name lives on the vacuum entity (`selected_map`), and the robot's Y axis
|
||||
is flipped versus the screen — hence mirror-on by default.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.52.2",
|
||||
"version": "1.55.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.52.2",
|
||||
"version": "1.55.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.52.2",
|
||||
"version": "1.55.2",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+1321
-26
File diff suppressed because it is too large
Load Diff
+48
-2
@@ -35,6 +35,9 @@
|
||||
"markup.add": "Add",
|
||||
"markup.merge": "Merge",
|
||||
"markup.split": "Split",
|
||||
"markup.resize": "Resize",
|
||||
"title.markup_resize": "Resize rooms: drag a wall handle; click a room for a corner scale frame",
|
||||
"markup.hint_resize": "drag a wall handle · click a room — corner frame · Esc cancels a drag · Ctrl+Z — undo a step",
|
||||
"markup.opening": "Opening",
|
||||
"title.markup_opening": "Doors & windows: click a wall to place, click an opening to edit",
|
||||
"opening.new": "New opening",
|
||||
@@ -340,5 +343,48 @@
|
||||
"toast.plans_list_failed": "Could not list the stored plans: {err}",
|
||||
"toast.plan_delete_failed": "Could not delete the plan: {err}",
|
||||
"marker.hide": "Hide device from plan",
|
||||
"marker.hide_tip": "The device is not drawn on the plan but still counts toward the room signal. To see it: the \"Show hidden\" button in the device editor."
|
||||
}
|
||||
"marker.hide_tip": "The device is not drawn on the plan but still counts toward the room signal. To see it: the \"Show hidden\" button in the device editor.",
|
||||
"tap.run": "Run automation/script/scene",
|
||||
"marker.run_target_label": "What to run",
|
||||
"marker.run_search_ph": "Search: automation, script or scene…",
|
||||
"marker.run_target_gone": "Target {id} not found — pick again",
|
||||
"marker.tap_confirm": "Ask for confirmation",
|
||||
"marker.tap_confirm_tip": "Show a confirmation dialog before acting — a guard against accidental taps.",
|
||||
"run.automation": "automation",
|
||||
"run.script": "script",
|
||||
"run.scene": "scene",
|
||||
"confirm.tap_run": "Run \"{name}\"?",
|
||||
"confirm.tap_toggle": "Toggle \"{name}\"?",
|
||||
"toast.run_started": "Started: {name}",
|
||||
"toast.run_target_missing": "Run target not found — check the device settings",
|
||||
"toast.run_target_required": "Pick an automation, script or scene",
|
||||
"btn.run": "Run",
|
||||
"vac.section": "Robot vacuum: live position",
|
||||
"vac.status_found": "Position source found: {name}",
|
||||
"vac.status_none": "The integration reports no coordinates — the robot will only be shown at its base",
|
||||
"vac.autocal": "Set up automatically",
|
||||
"vac.live": "Live position on the plan",
|
||||
"vac.trail": "Show the robot's path",
|
||||
"vac.cal_maps": "Calibrated maps: {maps}",
|
||||
"vac.autocal_no_rooms": "The integration reports no room list — open “Fit manually”",
|
||||
"vac.autocal_no_match": "Room names did not match (need ≥3 in common) — open “Fit manually”",
|
||||
"vac.autocal_res_warn": "Matched {rooms} rooms but the fit is rough — verify and refine via “Fit manually” if needed",
|
||||
"vac.autocal_done": "Done: bound via {rooms} rooms. Start a cleanup and check",
|
||||
"vac.cal_need_pos": "The robot is not reporting coordinates — start a cleanup and pause it",
|
||||
"vac.cal_done": "Calibration saved. Start a cleanup and check",
|
||||
"vac.cal_cancelled": "Calibration cancelled",
|
||||
"vac.fit": "Fit manually",
|
||||
"vac.fit_hint": "Drag the robot map into place, stretch by the corners",
|
||||
"vac.fit_rotate": "Rotate 90°",
|
||||
"vac.fit_mirror": "Mirror",
|
||||
"vac.trail_never": "Never",
|
||||
"vac.trail_cleaning": "While cleaning",
|
||||
"vac.trail_always": "Always",
|
||||
"gs.bg_group": "Stage background",
|
||||
"gs.bg_color": "Background around the plan",
|
||||
"gs.bg_default": "Theme default",
|
||||
"gs.bg_theme": "theme default",
|
||||
"space.bg_color": "Background around the plan",
|
||||
"space.bg_inherit": "Inherit general",
|
||||
"space.bg_inherited": "inherits general settings"
|
||||
}
|
||||
|
||||
+48
-2
@@ -35,6 +35,9 @@
|
||||
"markup.add": "Добавить",
|
||||
"markup.merge": "Объединить",
|
||||
"markup.split": "Разделить",
|
||||
"markup.resize": "Размер",
|
||||
"title.markup_resize": "Изменение размера комнат: тяните ручку стены; клик по комнате — угловая рамка масштаба",
|
||||
"markup.hint_resize": "тяните ручку стены · клик по комнате — угловая рамка · Esc отменяет перетаскивание · Ctrl+Z — отмена шага",
|
||||
"markup.opening": "Проём",
|
||||
"title.markup_opening": "Двери и окна: клик по стене — добавить, клик по проёму — редактировать",
|
||||
"opening.new": "Новый проём",
|
||||
@@ -340,5 +343,48 @@
|
||||
"toast.plans_list_failed": "Не удалось получить список планов: {err}",
|
||||
"toast.plan_delete_failed": "Не удалось удалить план: {err}",
|
||||
"marker.hide": "Скрыть устройство с плана",
|
||||
"marker.hide_tip": "Устройство не отображается на плане, но участвует в расчёте сигнала комнаты. Показать: кнопка «Показать скрытые» в редакторе устройств."
|
||||
}
|
||||
"marker.hide_tip": "Устройство не отображается на плане, но участвует в расчёте сигнала комнаты. Показать: кнопка «Показать скрытые» в редакторе устройств.",
|
||||
"tap.run": "Запустить автоматизацию/скрипт/сцену",
|
||||
"marker.run_target_label": "Что запускать",
|
||||
"marker.run_search_ph": "Поиск: автоматизация, скрипт или сцена…",
|
||||
"marker.run_target_gone": "Цель {id} не найдена — выберите заново",
|
||||
"marker.tap_confirm": "Спрашивать подтверждение",
|
||||
"marker.tap_confirm_tip": "Перед выполнением показать диалог подтверждения — защита от случайных нажатий.",
|
||||
"run.automation": "автоматизация",
|
||||
"run.script": "скрипт",
|
||||
"run.scene": "сцена",
|
||||
"confirm.tap_run": "Запустить «{name}»?",
|
||||
"confirm.tap_toggle": "Переключить «{name}»?",
|
||||
"toast.run_started": "Запущено: {name}",
|
||||
"toast.run_target_missing": "Цель запуска не найдена — проверьте настройки устройства",
|
||||
"toast.run_target_required": "Выберите автоматизацию, скрипт или сцену",
|
||||
"btn.run": "Выполнить",
|
||||
"vac.section": "Робот-пылесос: живая позиция",
|
||||
"vac.status_found": "Источник координат найден: {name}",
|
||||
"vac.status_none": "Интеграция не отдаёт координаты — робот будет показан только на базе",
|
||||
"vac.autocal": "Настроить автоматически",
|
||||
"vac.live": "Живая позиция на плане",
|
||||
"vac.trail": "Показывать путь робота",
|
||||
"vac.cal_maps": "Откалиброваны карты: {maps}",
|
||||
"vac.autocal_no_rooms": "Интеграция не отдаёт список комнат — откройте «Подогнать вручную»",
|
||||
"vac.autocal_no_match": "Не совпали имена комнат (нужно ≥3 общих) — откройте «Подогнать вручную»",
|
||||
"vac.autocal_res_warn": "Совпало комнат: {rooms}, но привязка грубовата — проверьте и при необходимости откройте «Подогнать вручную»",
|
||||
"vac.autocal_done": "Готово: привязка по {rooms} комнатам. Запустите уборку и проверьте",
|
||||
"vac.cal_need_pos": "Робот сейчас не отдаёт координаты — запустите уборку и поставьте на паузу",
|
||||
"vac.cal_done": "Калибровка сохранена. Запустите уборку и проверьте",
|
||||
"vac.cal_cancelled": "Калибровка отменена",
|
||||
"vac.fit": "Подогнать вручную",
|
||||
"vac.fit_hint": "Перетащите карту робота на место, растяните за уголки",
|
||||
"vac.fit_rotate": "Повернуть 90°",
|
||||
"vac.fit_mirror": "Отразить",
|
||||
"vac.trail_never": "Не показывать никогда",
|
||||
"vac.trail_cleaning": "Во время уборки",
|
||||
"vac.trail_always": "Показывать всегда",
|
||||
"gs.bg_group": "Фон сцены",
|
||||
"gs.bg_color": "Цвет фона вокруг плана",
|
||||
"gs.bg_default": "Как в теме",
|
||||
"gs.bg_theme": "по умолчанию — из темы",
|
||||
"space.bg_color": "Цвет фона вокруг плана",
|
||||
"space.bg_inherit": "Наследовать общий",
|
||||
"space.bg_inherited": "наследуется из общих настроек"
|
||||
}
|
||||
|
||||
+49
-5
@@ -596,7 +596,7 @@ export function safeUrl(url: string | null | undefined): string | null {
|
||||
|
||||
// ---------------- tap actions ----------------
|
||||
|
||||
export type TapAction = 'info' | 'more-info' | 'toggle';
|
||||
export type TapAction = 'info' | 'more-info' | 'toggle' | 'run';
|
||||
|
||||
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
|
||||
/**
|
||||
@@ -615,12 +615,16 @@ export type TapAction = 'info' | 'more-info' | 'toggle';
|
||||
* Adding an option here and forgetting the schema now fails the test suite.
|
||||
*/
|
||||
export const DISPLAY_MODES = ['badge', 'ripple', 'icon_ripple', 'value'] as const;
|
||||
export const TAP_ACTIONS = ['info', 'more-info', 'toggle'] as const;
|
||||
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run'] as const;
|
||||
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
|
||||
export const SPACE_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'glow'] as const;
|
||||
// 'glow' leads: it is the default for new spaces since v1.54 — the owner's
|
||||
// call, it sells the card best. Existing configs keep whatever they chose;
|
||||
// an absent fill_mode still falls back to 'none' (spaceDisplayOf), so an
|
||||
// update never repaints somebody's plan.
|
||||
export const SPACE_FILL_MODES = ['glow', 'none', 'lqi', 'light', 'temp'] as const;
|
||||
export const ROOM_FILL_MODES = ['none', 'lqi', 'light', 'temp'] as const;
|
||||
|
||||
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier']);
|
||||
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier', 'cover', 'valve']);
|
||||
|
||||
/**
|
||||
* Domains that must NEVER toggle from a plan tap, even with an explicit
|
||||
@@ -637,10 +641,16 @@ export const TOGGLE_FORBIDDEN_DOMAINS = new Set(['lock', 'alarm_control_panel'])
|
||||
* TOGGLE_SAFE_DOMAINS; an explicit per-device toggle affects any domain
|
||||
* except TOGGLE_FORBIDDEN_DOMAINS. Everything else falls back to 'info'.
|
||||
*/
|
||||
/** Cover classes that stay OUT of the card-wide toggle: an accidental tap
|
||||
* opening the garage or the driveway gate is a security matter, like locks.
|
||||
* An explicit per-device toggle remains the owner's conscious choice. */
|
||||
export const COVER_GUARDED_CLASSES = new Set(['garage', 'door', 'gate']);
|
||||
|
||||
export function resolveTapAction(
|
||||
explicit: string | null | undefined,
|
||||
cardDefault: string | null | undefined,
|
||||
domain: string | null | undefined,
|
||||
deviceClass?: string | null,
|
||||
): TapAction {
|
||||
// Pure light sources (the device's PRIMARY function is a lamp: bulbs,
|
||||
// chandeliers, night lights, light groups) toggle by default — no explicit
|
||||
@@ -648,10 +658,30 @@ export function resolveTapAction(
|
||||
// backlight) have a non-light primary and keep the info default.
|
||||
const want = explicit || cardDefault || (domain === 'light' ? 'toggle' : 'info');
|
||||
if (want === 'more-info') return 'more-info';
|
||||
// 'run' is EXPLICIT-only by construction: it needs a per-marker target, so
|
||||
// it can never arrive as a card-wide default
|
||||
if (want === 'run') return explicit === 'run' ? 'run' : 'info';
|
||||
if (want !== 'toggle') return 'info';
|
||||
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
|
||||
if (explicit === 'toggle') return 'toggle';
|
||||
return TOGGLE_SAFE_DOMAINS.has(domain) ? 'toggle' : 'info';
|
||||
if (!TOGGLE_SAFE_DOMAINS.has(domain)) return 'info';
|
||||
// covers joined the safe set for curtains and blinds (owner, 2026-07-29) —
|
||||
// but the garage door is a cover too, and it stays shut on a default tap
|
||||
if (domain === 'cover' && COVER_GUARDED_CLASSES.has(String(deviceClass || ''))) return 'info';
|
||||
return 'toggle';
|
||||
}
|
||||
|
||||
/** Domains a tap may RUN (owner's spec 2026-07-29): the runnable units of
|
||||
* HA. An automation is triggered, a script and a scene are turned on. */
|
||||
export const RUN_TARGET_DOMAINS = ['automation', 'script', 'scene'] as const;
|
||||
|
||||
/** Service to start a runnable target, or null when the id is not runnable. */
|
||||
export function runServiceFor(target: string | null | undefined): { domain: string; service: string } | null {
|
||||
const dom = String(target || '').split('.')[0];
|
||||
if (dom === 'automation') return { domain: 'automation', service: 'trigger' };
|
||||
if (dom === 'script') return { domain: 'script', service: 'turn_on' };
|
||||
if (dom === 'scene') return { domain: 'scene', service: 'turn_on' };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------- floors import ----------------
|
||||
@@ -709,6 +739,8 @@ export interface SpaceDisplay {
|
||||
labelHum: boolean;
|
||||
labelLqi: boolean;
|
||||
labelLight: boolean;
|
||||
/** Background around the plan; null = inherit the global setting / theme. */
|
||||
bgColor: string | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
|
||||
@@ -736,9 +768,21 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
|
||||
labelHum: s.label_hum === true,
|
||||
labelLqi: s.label_lqi === true,
|
||||
labelLight: s.label_light === true,
|
||||
bgColor: typeof s.bg_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.bg_color) ? s.bg_color : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective background color around the plan: the per-space override wins,
|
||||
* then the global config.settings.bg_color, then '' — meaning "keep the
|
||||
* theme default" (the stage's stylesheet background stays untouched).
|
||||
*/
|
||||
export function stageBgOf(settings: any, disp: { bgColor: string | null }): string {
|
||||
if (disp.bgColor) return disp.bgColor;
|
||||
const g = settings?.bg_color;
|
||||
return typeof g === 'string' && /^#[0-9a-f]{6}$/i.test(g) ? g : '';
|
||||
}
|
||||
|
||||
// ---------------- global fill colors ----------------
|
||||
|
||||
export interface FillColorEntry {
|
||||
|
||||
+605
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* Room resize geometry — pure functions only (docs/RESIZE.md).
|
||||
*
|
||||
* Mechanism A: dragging a wall along its normal, shared stretches of
|
||||
* neighbours move together (T-junctions insert vertices). Mechanism B:
|
||||
* uniform scale of one room about a bbox corner. Every stop («упор») is
|
||||
* decided here so it can be unit-tested; the card only wires pointers.
|
||||
*
|
||||
* All coordinates are render units (NORM_W-scaled), same as the card's
|
||||
* space model. Nothing here touches Lit or the DOM.
|
||||
*/
|
||||
import { intersection } from 'polyclip-ts';
|
||||
import {
|
||||
polygonArea, segmentsProperlyCross, polyContainsPoly, roomsOverlap,
|
||||
} from './logic';
|
||||
|
||||
/** Minimal room dimension in centimetres (owner: «мин. габарит ~30 см»). */
|
||||
export const MIN_ROOM_CM = 30;
|
||||
|
||||
export interface RoomIn { id: string; poly: number[][] }
|
||||
/** Opening in render units: centre, wall angle (deg), full length. */
|
||||
export interface OpeningIn { id: string; x: number; y: number; length: number }
|
||||
|
||||
export interface EdgeDragPlan {
|
||||
roomId: string;
|
||||
edge: number; // edge index i: v[i] -> v[i+1]
|
||||
a: number[]; // edge endpoints BEFORE the drag
|
||||
b: number[];
|
||||
n: [number, number]; // outward unit normal (d > 0 grows the room)
|
||||
}
|
||||
|
||||
export interface EdgeDragResult {
|
||||
/** roomId -> new outline (only rooms that changed). */
|
||||
polys: Record<string, number[][]>;
|
||||
/** openingId -> new centre (only openings that travelled with the wall). */
|
||||
openings: Record<string, [number, number]>;
|
||||
/** roomId -> the moved stretches AFTER the move (for clearance/labels). */
|
||||
movedSpans: Record<string, [number[], number[]][]>;
|
||||
}
|
||||
|
||||
// ---------------- tiny vector helpers ----------------
|
||||
|
||||
const sub = (p: number[], q: number[]) => [p[0] - q[0], p[1] - q[1]];
|
||||
const add2 = (p: number[], d: number[]) => [p[0] + d[0], p[1] + d[1]];
|
||||
const dot = (p: number[], q: number[]) => p[0] * q[0] + p[1] * q[1];
|
||||
const len2d = (p: number[]) => Math.hypot(p[0], p[1]);
|
||||
|
||||
function signedArea(poly: number[][]): number {
|
||||
let s = 0;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||||
s += a[0] * b[1] - b[0] * a[1];
|
||||
}
|
||||
return s / 2;
|
||||
}
|
||||
|
||||
function pointInPoly(p: number[], poly: number[][]): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const xi = poly[i][0], yi = poly[i][1], xj = poly[j][0], yj = poly[j][1];
|
||||
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
function distPointToSpan(p: number[], a: number[], b: number[]): number {
|
||||
const ab = sub(b, a);
|
||||
const l2 = dot(ab, ab);
|
||||
if (l2 < 1e-12) return len2d(sub(p, a));
|
||||
let t = dot(sub(p, a), ab) / l2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return len2d(sub(p, [a[0] + ab[0] * t, a[1] + ab[1] * t]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Outward unit normal of edge i. Candidate is the +90° rotation of the edge
|
||||
* direction; a probe point decides the sign, so polygon orientation (either
|
||||
* winding survives in real configs) does not matter.
|
||||
*/
|
||||
export function edgeNormal(poly: number[][], i: number): [number, number] {
|
||||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||||
const d = sub(b, a);
|
||||
const l = len2d(d) || 1;
|
||||
let n: [number, number] = [d[1] / l, -d[0] / l];
|
||||
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
const probe = Math.max(l * 0.01, 1e-4);
|
||||
if (pointInPoly([mid[0] + n[0] * probe, mid[1] + n[1] * probe], poly)) n = [-n[0], -n[1]];
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Translate BOTH endpoints of edge i by the normal times d (docs/RESIZE.md, mechanism A). */
|
||||
export function movePolyEdge(poly: number[][], i: number, d: number, n?: [number, number]): number[][] {
|
||||
const nn = n || edgeNormal(poly, i);
|
||||
const j = (i + 1) % poly.length;
|
||||
return poly.map((p, k) => (k === i || k === j ? [p[0] + nn[0] * d, p[1] + nn[1] * d] : [...p]));
|
||||
}
|
||||
|
||||
/** Collinear overlap stretches of `poly`'s edges with segment a-b, as [p,q] pairs (pre-move). */
|
||||
export function sharedSpansWith(poly: number[][], a: number[], b: number[], eps: number): [number[], number[]][] {
|
||||
const out: [number[], number[]][] = [];
|
||||
const ab = sub(b, a);
|
||||
const L = len2d(ab);
|
||||
if (L < eps) return out;
|
||||
const u = [ab[0] / L, ab[1] / L];
|
||||
for (let j = 0; j < poly.length; j++) {
|
||||
const q1 = poly[j], q2 = poly[(j + 1) % poly.length];
|
||||
const off1 = Math.abs((q1[0] - a[0]) * u[1] - (q1[1] - a[1]) * u[0]);
|
||||
const off2 = Math.abs((q2[0] - a[0]) * u[1] - (q2[1] - a[1]) * u[0]);
|
||||
if (off1 > eps || off2 > eps) continue; // not collinear with a-b
|
||||
const t1 = dot(sub(q1, a), u);
|
||||
const t2 = dot(sub(q2, a), u);
|
||||
const lo = Math.max(0, Math.min(t1, t2));
|
||||
const hi = Math.min(L, Math.max(t1, t2));
|
||||
if (hi - lo > eps) out.push([[a[0] + u[0] * lo, a[1] + u[1] * lo], [a[0] + u[0] * hi, a[1] + u[1] * hi]]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Neighbour sync: translate the stretches of `poly` that coincide with segment
|
||||
* a-b by vector D, inserting vertices at partial-contact boundaries (T-junction
|
||||
* → the neighbour may become L-shaped). Returns null when nothing coincides.
|
||||
*/
|
||||
export function shiftSharedSpans(
|
||||
poly: number[][], a: number[], b: number[], D: [number, number], eps: number,
|
||||
): number[][] | null {
|
||||
const spans = sharedSpansWith(poly, a, b, eps);
|
||||
if (!spans.length) return null;
|
||||
const onSpan = (p: number[]) => spans.some(([p1, p2]) => distPointToSpan(p, p1, p2) <= eps);
|
||||
const n = poly.length;
|
||||
const out: number[][] = [];
|
||||
for (let j = 0; j < n; j++) {
|
||||
const q1 = poly[j], q2 = poly[(j + 1) % n];
|
||||
out.push(onSpan(q1) ? add2(q1, D) : [...q1]);
|
||||
const e = sub(q2, q1);
|
||||
const elen = len2d(e);
|
||||
if (elen < eps) continue;
|
||||
const u = [e[0] / elen, e[1] / elen];
|
||||
// collinear with a-b? (both endpoints on the a-b LINE)
|
||||
const abL = len2d(sub(b, a)) || 1;
|
||||
const uv = [(b[0] - a[0]) / abL, (b[1] - a[1]) / abL];
|
||||
const o1 = Math.abs((q1[0] - a[0]) * uv[1] - (q1[1] - a[1]) * uv[0]);
|
||||
const o2 = Math.abs((q2[0] - a[0]) * uv[1] - (q2[1] - a[1]) * uv[0]);
|
||||
if (o1 > eps || o2 > eps) continue;
|
||||
const tA = dot(sub(a, q1), u);
|
||||
const tB = dot(sub(b, q1), u);
|
||||
const lo = Math.max(0, Math.min(tA, tB));
|
||||
const hi = Math.min(elen, Math.max(tA, tB));
|
||||
if (hi - lo <= eps) continue;
|
||||
// interior boundaries split the edge: entering the overlap emits the static
|
||||
// point then its moved copy, leaving it emits the moved copy then the static
|
||||
if (lo > eps && lo < elen - eps) {
|
||||
const p = [q1[0] + u[0] * lo, q1[1] + u[1] * lo];
|
||||
out.push([...p], add2(p, D));
|
||||
}
|
||||
if (hi > eps && hi < elen - eps) {
|
||||
const p = [q1[0] + u[0] * hi, q1[1] + u[1] * hi];
|
||||
out.push(add2(p, D), [...p]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop consecutive duplicates and collinear middle vertices (commit-time cleanup). */
|
||||
export function simplifyPoly(poly: number[][], eps = 1e-6): number[][] {
|
||||
let pts = poly.filter((p, i) => len2d(sub(p, poly[(i + 1) % poly.length])) > eps);
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
pts = pts.filter((p, i) => {
|
||||
const prev = pts[(i - 1 + pts.length) % pts.length];
|
||||
const next = pts[(i + 1) % pts.length];
|
||||
const cross = (p[0] - prev[0]) * (next[1] - prev[1]) - (p[1] - prev[1]) * (next[0] - prev[0]);
|
||||
const span = len2d(sub(next, prev)) || 1;
|
||||
return Math.abs(cross) / span > eps; // keep only real corners
|
||||
});
|
||||
}
|
||||
return pts.length >= 3 ? pts : poly;
|
||||
}
|
||||
|
||||
/** Simple polygon: no properly crossing edges (shared walls touching is fine). */
|
||||
export function polyIsSimple(poly: number[][]): boolean {
|
||||
const n = poly.length;
|
||||
if (n < 3) return false;
|
||||
for (let i = 0; i < n; i++)
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
if (j === i || (j + 1) % n === i || (i + 1) % n === j) continue;
|
||||
if (segmentsProperlyCross(poly[i], poly[(i + 1) % n], poly[j], poly[(j + 1) % n])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normal clearance between the moved stretches and every PARALLEL wall of the
|
||||
* same room with an overlapping projection — the «opposite wall» distance that
|
||||
* enforces the 30 cm minimum. Infinity when no opposite wall exists.
|
||||
*/
|
||||
export function minParallelClearance(
|
||||
poly: number[][], spans: [number[], number[]][], eps = 1e-6,
|
||||
): number {
|
||||
let best = Infinity;
|
||||
for (const [a, b] of spans) {
|
||||
const ab = sub(b, a);
|
||||
const L = len2d(ab);
|
||||
if (L < eps) continue;
|
||||
const u = [ab[0] / L, ab[1] / L];
|
||||
for (let j = 0; j < poly.length; j++) {
|
||||
const q1 = poly[j], q2 = poly[(j + 1) % poly.length];
|
||||
const e = sub(q2, q1);
|
||||
const elen = len2d(e);
|
||||
if (elen < eps) continue;
|
||||
const cosang = Math.abs((e[0] * u[0] + e[1] * u[1]) / elen);
|
||||
if (cosang < 1 - 1e-4) continue; // not parallel
|
||||
// projection overlap along the span direction
|
||||
const t1 = dot(sub(q1, a), u);
|
||||
const t2 = dot(sub(q2, a), u);
|
||||
const lo = Math.max(0, Math.min(t1, t2));
|
||||
const hi = Math.min(L, Math.max(t1, t2));
|
||||
if (hi - lo <= eps) continue;
|
||||
const d1 = Math.abs((q1[0] - a[0]) * u[1] - (q1[1] - a[1]) * u[0]);
|
||||
if (d1 <= eps) continue; // the span itself / collinear leftovers
|
||||
if (d1 < best) best = d1;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* HP-1550-02: orientation-independent clearance of the moved stretches.
|
||||
*
|
||||
* minParallelClearance only saw PARALLEL opposite walls, so a triangle (no
|
||||
* parallel wall at all) reported Infinity and the 30 cm floor was simply off —
|
||||
* the base could be dragged to a 5-unit sliver. This measure looks at the whole
|
||||
* band the span sweeps along its normal: every vertex strictly inside the band
|
||||
* and every edge crossing the band interior counts with its perpendicular
|
||||
* distance from the span line. Two exclusions keep it honest:
|
||||
* - anything ON the span line (offset ≤ eps) is the span itself, a collinear
|
||||
* wall remainder or a T-insert — not an opposite obstacle;
|
||||
* - the band ENDS (projection ≤ eps or ≥ L − eps) are excluded, so the
|
||||
* |d|-long step edge a T-junction inserts at the very end of the span does
|
||||
* not read as a paper-thin room on every small drag.
|
||||
* Offsets cannot change sign inside the band (that would cross the span —
|
||||
* polyIsSimple already rejected it), so an edge's minimum lies at a clip bound.
|
||||
* Infinity still means «nothing opposite at all» (e.g. growing outward).
|
||||
*/
|
||||
export function minSpanClearance(
|
||||
poly: number[][], spans: [number[], number[]][], eps = 1e-6,
|
||||
): number {
|
||||
let best = Infinity;
|
||||
for (const [a, b] of spans) {
|
||||
const ab = sub(b, a);
|
||||
const L = len2d(ab);
|
||||
if (L < eps) continue;
|
||||
const u = [ab[0] / L, ab[1] / L];
|
||||
const soff = (p: number[]) => (p[0] - a[0]) * u[1] - (p[1] - a[1]) * u[0];
|
||||
const tOf = (p: number[]) => (p[0] - a[0]) * u[0] + (p[1] - a[1]) * u[1];
|
||||
const lo = eps, hi = L - eps;
|
||||
for (const v of poly) {
|
||||
const o = Math.abs(soff(v));
|
||||
if (o <= eps) continue;
|
||||
const tv = tOf(v);
|
||||
if (tv <= lo || tv >= hi) continue;
|
||||
if (o < best) best = o;
|
||||
}
|
||||
for (let j = 0; j < poly.length; j++) {
|
||||
const q1 = poly[j], q2 = poly[(j + 1) % poly.length];
|
||||
const o1 = soff(q1), o2 = soff(q2);
|
||||
if (Math.abs(o1) <= eps || Math.abs(o2) <= eps) continue; // attached to the moving wall
|
||||
const t1 = tOf(q1), t2 = tOf(q2);
|
||||
const tlo = Math.max(lo, Math.min(t1, t2));
|
||||
const thi = Math.min(hi, Math.max(t1, t2));
|
||||
if (thi - tlo <= eps) continue; // casts no shadow on the span interior
|
||||
const dt = t2 - t1;
|
||||
if (Math.abs(dt) < eps) { // perpendicular-ish edge fully inside the band
|
||||
best = Math.min(best, Math.abs(o1), Math.abs(o2));
|
||||
continue;
|
||||
}
|
||||
const offAt = (tt: number) => Math.abs(o1 + ((tt - t1) / dt) * (o2 - o1));
|
||||
best = Math.min(best, offAt(tlo), offAt(thi));
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Convex hull (monotone chain) — only the width measure below needs it. */
|
||||
function convexHull(pts: number[][]): number[][] {
|
||||
const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
if (p.length < 3) return p;
|
||||
const cross = (o: number[], a: number[], b: number[]) =>
|
||||
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
const lower: number[][] = [];
|
||||
for (const pt of p) {
|
||||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop();
|
||||
lower.push(pt);
|
||||
}
|
||||
const upper: number[][] = [];
|
||||
for (let i = p.length - 1; i >= 0; i--) {
|
||||
const pt = p[i];
|
||||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop();
|
||||
upper.push(pt);
|
||||
}
|
||||
lower.pop(); upper.pop();
|
||||
return lower.concat(upper);
|
||||
}
|
||||
|
||||
/**
|
||||
* HP-1550-02: the TRUE minimum width of a polygon — rotating calipers over the
|
||||
* convex hull (the min over hull edge directions of the perpendicular extent).
|
||||
* The axis-aligned bbox lied under rotation: a 500×100 rectangle turned 45° has
|
||||
* a ≈424×424 bbox, so a 0.1 scale slid the real 100-side down to 10 unchecked.
|
||||
* A similarity scales every distance by k, which makes k·minPolyWidth exact —
|
||||
* and a concave room is judged by its overall silhouette, so a small notch
|
||||
* that takes no part in the operation cannot veto a legal scale.
|
||||
*/
|
||||
export function minPolyWidth(poly: number[][]): number {
|
||||
const h = convexHull(poly);
|
||||
if (h.length < 3) return 0;
|
||||
let best = Infinity;
|
||||
for (let i = 0; i < h.length; i++) {
|
||||
const a = h[i], b = h[(i + 1) % h.length];
|
||||
const e = sub(b, a);
|
||||
const L = len2d(e);
|
||||
if (L < 1e-12) continue;
|
||||
const u = [e[0] / L, e[1] / L];
|
||||
let w = 0;
|
||||
for (const pt of h) w = Math.max(w, Math.abs((pt[0] - a[0]) * u[1] - (pt[1] - a[1]) * u[0]));
|
||||
if (w < best) best = w;
|
||||
}
|
||||
return Number.isFinite(best) ? best : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two outlines ILLEGALLY share floor area? `roomsOverlap` alone misses the
|
||||
* «slide-over» case: equal-height rectangles overlapping horizontally have all
|
||||
* their edge intersections on collinear stretches, so nothing «properly
|
||||
* crosses» and nothing is strictly inside. A real polygon intersection area
|
||||
* settles it; legal full nesting (island rooms) stays legal.
|
||||
*/
|
||||
export function illegalOverlap(a: number[][], b: number[][], eps: number): boolean {
|
||||
if (roomsOverlap(a, b, eps)) return true;
|
||||
if (polyContainsPoly(a, b, eps) || polyContainsPoly(b, a, eps)) return false;
|
||||
let area = 0;
|
||||
try {
|
||||
const res = intersection(
|
||||
[[...a.map((p) => [p[0], p[1]]), [a[0][0], a[0][1]]]] as any,
|
||||
[[...b.map((p) => [p[0], p[1]]), [b[0][0], b[0][1]]]] as any,
|
||||
);
|
||||
for (const poly of res as any) if (poly?.[0]) area += polygonArea(poly[0]);
|
||||
} catch {
|
||||
return false; // a degenerate clip must not block the drag; the other stops still hold
|
||||
}
|
||||
return area > Math.max(1e-7, eps * eps);
|
||||
}
|
||||
|
||||
// ---------------- mechanism A: the full drag pipeline ----------------
|
||||
|
||||
export function planEdgeDrag(rooms: RoomIn[], roomId: string, edge: number): EdgeDragPlan | null {
|
||||
const room = rooms.find((r) => r.id === roomId);
|
||||
if (!room || !room.poly || room.poly.length < 3) return null;
|
||||
if (edge < 0 || edge >= room.poly.length) return null;
|
||||
const a = [...room.poly[edge]];
|
||||
const b = [...room.poly[(edge + 1) % room.poly.length]];
|
||||
return { roomId, edge, a, b, n: edgeNormal(room.poly, edge) };
|
||||
}
|
||||
|
||||
/** Apply the drag at distance d: own edge + every coinciding neighbour stretch. */
|
||||
export function applyEdgeDrag(
|
||||
rooms: RoomIn[], openings: OpeningIn[], plan: EdgeDragPlan, d: number, eps: number,
|
||||
): EdgeDragResult {
|
||||
const D: [number, number] = [plan.n[0] * d, plan.n[1] * d];
|
||||
const res: EdgeDragResult = { polys: {}, openings: {}, movedSpans: {} };
|
||||
if (Math.abs(d) < 1e-9) return res;
|
||||
for (const r of rooms) {
|
||||
if (r.id === plan.roomId) {
|
||||
res.polys[r.id] = movePolyEdge(r.poly, plan.edge, d, plan.n);
|
||||
res.movedSpans[r.id] = [[add2(plan.a, D), add2(plan.b, D)]];
|
||||
continue;
|
||||
}
|
||||
const spans = sharedSpansWith(r.poly, plan.a, plan.b, eps);
|
||||
if (!spans.length) continue;
|
||||
const shifted = shiftSharedSpans(r.poly, plan.a, plan.b, D, eps);
|
||||
if (shifted) {
|
||||
res.polys[r.id] = shifted;
|
||||
res.movedSpans[r.id] = spans.map(([p, q]) => [add2(p, D), add2(q, D)] as [number[], number[]]);
|
||||
}
|
||||
}
|
||||
// openings ON the moving wall travel with it (docs/RESIZE.md: anchors)
|
||||
for (const o of openings) {
|
||||
if (distPointToSpan([o.x, o.y], plan.a, plan.b) <= eps) res.openings[o.id] = [o.x + D[0], o.y + D[1]];
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/** An opening must sit fully on ONE wall of some room: centre on the edge, both ends within it. */
|
||||
function openingFits(o: { x: number; y: number; length: number }, polys: number[][][], eps: number): boolean {
|
||||
for (const poly of polys) {
|
||||
for (let j = 0; j < poly.length; j++) {
|
||||
const q1 = poly[j], q2 = poly[(j + 1) % poly.length];
|
||||
const e = sub(q2, q1);
|
||||
const elen = len2d(e);
|
||||
if (elen < eps) continue;
|
||||
const u = [e[0] / elen, e[1] / elen];
|
||||
const off = Math.abs((o.x - q1[0]) * u[1] - (o.y - q1[1]) * u[0]);
|
||||
if (off > eps) continue;
|
||||
const t = (o.x - q1[0]) * u[0] + (o.y - q1[1]) * u[1];
|
||||
if (t - o.length / 2 >= -eps && t + o.length / 2 <= elen + eps) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Openings that sit on any wall of any of the given (pre-move) outlines. */
|
||||
function openingsOnRooms(openings: OpeningIn[], polys: number[][][], eps: number): OpeningIn[] {
|
||||
return openings.filter((o) =>
|
||||
polys.some((poly) => {
|
||||
for (let j = 0; j < poly.length; j++)
|
||||
if (distPointToSpan([o.x, o.y], poly[j], poly[(j + 1) % poly.length]) <= eps) return true;
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export interface StopOpts {
|
||||
minDim: number; // canvas units (≈30 cm through cell_cm)
|
||||
eps: number; // collinearity epsilon (canvas units)
|
||||
}
|
||||
|
||||
/** All the stops of docs/RESIZE.md for one candidate distance. */
|
||||
export function validateEdgeDrag(
|
||||
rooms: RoomIn[], openings: OpeningIn[], plan: EdgeDragPlan, d: number, opts: StopOpts,
|
||||
): boolean {
|
||||
const { minDim, eps } = opts;
|
||||
if (!Number.isFinite(d)) return false;
|
||||
if (Math.abs(d) < 1e-9) return true;
|
||||
const res = applyEdgeDrag(rooms, openings, plan, d, eps);
|
||||
const changedIds = Object.keys(res.polys);
|
||||
const newPolyOf = (r: RoomIn) => res.polys[r.id] || r.poly;
|
||||
for (const id of changedIds) {
|
||||
const r = rooms.find((x) => x.id === id)!;
|
||||
const np = res.polys[id];
|
||||
// simple + orientation preserved + not degenerate
|
||||
if (!polyIsSimple(np)) return false;
|
||||
const s0 = signedArea(r.poly), s1 = signedArea(np);
|
||||
if (Math.abs(s1) < eps || s0 * s1 <= 0) return false;
|
||||
// minimum size: orientation-independent clearance of the moved stretches
|
||||
// (HP-1550-02 — the parallel-walls-only measure left triangles and other
|
||||
// non-parallel geometry without the 30 cm floor); a room already thinner
|
||||
// keeps its clearance (improving is allowed, worsening is not)
|
||||
const oldSpans: [number[], number[]][] = id === plan.roomId
|
||||
? [[plan.a, plan.b]]
|
||||
: sharedSpansWith(r.poly, plan.a, plan.b, eps);
|
||||
const cOld = minSpanClearance(r.poly, oldSpans, eps);
|
||||
const cNew = minSpanClearance(np, res.movedSpans[id] || [], eps);
|
||||
if (cNew < Math.min(minDim, cOld) - eps) return false;
|
||||
// every pre-existing room relationship must SURVIVE the drag: an island
|
||||
// stays an island (a jump fully past a thin island crosses no edge, so
|
||||
// containment is checked explicitly), a nested room stays inside its
|
||||
// parent, and unrelated rooms must not start sharing area or nesting
|
||||
for (const other of rooms) {
|
||||
if (other.id === id) continue;
|
||||
const otherNew = newPolyOf(other);
|
||||
if (polyContainsPoly(r.poly, other.poly, eps)) { // our island
|
||||
if (!polyContainsPoly(np, otherNew, eps)) return false;
|
||||
continue;
|
||||
}
|
||||
if (polyContainsPoly(other.poly, r.poly, eps)) { // we are the island
|
||||
if (!polyContainsPoly(otherNew, np, eps)) return false;
|
||||
continue;
|
||||
}
|
||||
if (polyContainsPoly(np, otherNew, eps) || polyContainsPoly(otherNew, np, eps)) return false;
|
||||
if (illegalOverlap(np, otherNew, eps)) return false;
|
||||
}
|
||||
}
|
||||
// openings: everything that sat on a wall of an affected room must still fit
|
||||
const oldPolys = changedIds.map((id) => rooms.find((x) => x.id === id)!.poly);
|
||||
const allNew = rooms.map(newPolyOf);
|
||||
for (const o of openingsOnRooms(openings, oldPolys, eps * 2)) {
|
||||
const c = res.openings[o.id];
|
||||
const moved = c ? { ...o, x: c[0], y: c[1] } : o;
|
||||
if (!openingFits(moved, allNew, eps * 2)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest valid distance toward dWanted, stepping back by `step` (the grid
|
||||
* pitch, so a stopped wall still lands on the grid). 0 = the wall stays put.
|
||||
*/
|
||||
export function clampEdgeDrag(
|
||||
rooms: RoomIn[], openings: OpeningIn[], plan: EdgeDragPlan, dWanted: number, step: number, opts: StopOpts,
|
||||
): number {
|
||||
if (!Number.isFinite(dWanted) || Math.abs(dWanted) < 1e-9) return 0;
|
||||
const sign = Math.sign(dWanted);
|
||||
let mag = Math.abs(dWanted);
|
||||
const s = Math.max(step, 1e-6);
|
||||
for (let guard = 0; guard < 4096 && mag > 1e-9; guard++, mag -= s) {
|
||||
const d = sign * mag;
|
||||
if (validateEdgeDrag(rooms, openings, plan, d, opts)) return d;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------- mechanism B: the scale frame ----------------
|
||||
|
||||
export interface ScaleResult {
|
||||
poly: number[][];
|
||||
openings: Record<string, [number, number]>;
|
||||
}
|
||||
|
||||
/** Uniform scale of the room about `fixed`; exclusive openings follow, shared ones stay. */
|
||||
export function applyRoomScale(
|
||||
room: RoomIn, openings: OpeningIn[], otherPolys: number[][][], fixed: [number, number], k: number, eps: number,
|
||||
): ScaleResult {
|
||||
const scalePt = (p: number[]) => [fixed[0] + (p[0] - fixed[0]) * k, fixed[1] + (p[1] - fixed[1]) * k];
|
||||
const res: ScaleResult = { poly: room.poly.map(scalePt), openings: {} };
|
||||
for (const o of openings) {
|
||||
let on = false;
|
||||
for (let j = 0; j < room.poly.length; j++)
|
||||
if (distPointToSpan([o.x, o.y], room.poly[j], room.poly[(j + 1) % room.poly.length]) <= eps) { on = true; break; }
|
||||
if (!on) continue;
|
||||
// an opening on a wall shared with a neighbour belongs to the neighbour's
|
||||
// wall once the scale detaches ours — it stays put (docs/RESIZE.md)
|
||||
const shared = otherPolys.some((poly) => {
|
||||
for (let j = 0; j < poly.length; j++)
|
||||
if (distPointToSpan([o.x, o.y], poly[j], poly[(j + 1) % poly.length]) <= eps) return true;
|
||||
return false;
|
||||
});
|
||||
if (!shared) {
|
||||
const c = scalePt([o.x, o.y]);
|
||||
res.openings[o.id] = [c[0], c[1]];
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Stops for one candidate scale factor (neighbours are never dragged along). */
|
||||
export function validateRoomScale(
|
||||
rooms: RoomIn[], openings: OpeningIn[], roomId: string, fixed: [number, number], k: number, opts: StopOpts,
|
||||
): boolean {
|
||||
const { minDim, eps } = opts;
|
||||
if (!Number.isFinite(k) || k <= 0) return false;
|
||||
if (Math.abs(k - 1) < 1e-9) return true;
|
||||
const room = rooms.find((r) => r.id === roomId);
|
||||
if (!room) return false;
|
||||
const otherPolys = rooms.filter((r) => r.id !== roomId).map((r) => r.poly);
|
||||
const res = applyRoomScale(room, openings, otherPolys, fixed, k, eps * 2);
|
||||
const np = res.poly;
|
||||
// minimum size: a similarity scales every distance by exactly k, so the TRUE
|
||||
// minimum width of the original scales to k·w0 (HP-1550-02 — the axis-aligned
|
||||
// bbox side let a rotated rectangle shrink its real short side unchecked);
|
||||
// an already-thin room keeps the improve-only rule
|
||||
const w0 = minPolyWidth(room.poly);
|
||||
if (w0 * k < Math.min(minDim, w0) - eps) return false;
|
||||
// the neighbour is a wall to hit: pre-existing nesting must survive,
|
||||
// everything else must not gain shared area or become nested (engulfing a
|
||||
// foreign room via scale is a stop, not a new island)
|
||||
for (const r of rooms) {
|
||||
if (r.id === roomId) continue;
|
||||
if (polyContainsPoly(room.poly, r.poly, eps)) { // our island
|
||||
if (!polyContainsPoly(np, r.poly, eps)) return false;
|
||||
continue;
|
||||
}
|
||||
if (polyContainsPoly(r.poly, room.poly, eps)) { // we are the island
|
||||
if (!polyContainsPoly(r.poly, np, eps)) return false;
|
||||
continue;
|
||||
}
|
||||
if (polyContainsPoly(np, r.poly, eps) || polyContainsPoly(r.poly, np, eps)) return false;
|
||||
if (illegalOverlap(np, r.poly, eps)) return false;
|
||||
}
|
||||
// openings of this room (moved or kept) must still fit on some wall
|
||||
const allNew = rooms.map((r) => (r.id === roomId ? np : r.poly));
|
||||
for (const o of openingsOnRooms(openings, [room.poly], eps * 2)) {
|
||||
const c = res.openings[o.id];
|
||||
const moved = c ? { ...o, x: c[0], y: c[1] } : o;
|
||||
if (!openingFits(moved, allNew, eps * 2)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Closest valid factor to kWanted (bisecting toward 1, which is always valid). */
|
||||
export function clampRoomScale(
|
||||
rooms: RoomIn[], openings: OpeningIn[], roomId: string, fixed: [number, number], kWanted: number, opts: StopOpts,
|
||||
): number {
|
||||
if (!Number.isFinite(kWanted) || kWanted <= 0) return 1;
|
||||
if (validateRoomScale(rooms, openings, roomId, fixed, kWanted, opts)) return kWanted;
|
||||
let good = 1, bad = kWanted;
|
||||
for (let i = 0; i < 28; i++) {
|
||||
const mid = (good + bad) / 2;
|
||||
if (validateRoomScale(rooms, openings, roomId, fixed, mid, opts)) good = mid;
|
||||
else bad = mid;
|
||||
}
|
||||
return good;
|
||||
}
|
||||
|
||||
// ---------------- live numbers ----------------
|
||||
|
||||
/** Room area in m² from render units via the grid scale. */
|
||||
export function areaM2(poly: number[][], gridPitch: number, cellCm: number): number {
|
||||
const cmPerUnit = cellCm / gridPitch;
|
||||
return (polygonArea(poly) * cmPerUnit * cmPerUnit) / 1e4;
|
||||
}
|
||||
|
||||
/** "12.4 m²" or "133 ft²" per the HA unit system. */
|
||||
export function formatArea(m2: number, imperial: boolean): string {
|
||||
if (imperial) return `${Math.round(m2 * 10.7639)} ft²`;
|
||||
return `${(Math.round(m2 * 10) / 10).toFixed(1)} m²`;
|
||||
}
|
||||
+4
-2
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { html, svg, nothing, type TemplateResult } from 'lit';
|
||||
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
|
||||
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf } from './logic';
|
||||
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf } from './logic';
|
||||
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
|
||||
import { t, type Lang } from './i18n';
|
||||
import type { ServerConfig } from './types';
|
||||
@@ -146,9 +146,11 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
: [];
|
||||
|
||||
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
|
||||
// the static card paints the same background around the plan as the full one
|
||||
const stageBg = stageBgOf(o.cfg?.settings, disp);
|
||||
|
||||
return html`
|
||||
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}">
|
||||
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''}">
|
||||
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
|
||||
${bgHref
|
||||
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
|
||||
|
||||
+308
-17
@@ -151,6 +151,44 @@ export const cardStyles = css`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
/* HP-1552: first-open boot veil — the plan hides until the stage height settles */
|
||||
.stage.hpboot .zoomwrap,
|
||||
.stage.hpboot .zoombadge {
|
||||
visibility: hidden;
|
||||
}
|
||||
.bootveil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--hp-bg, #16212e);
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.bootveil.off {
|
||||
opacity: 0;
|
||||
}
|
||||
.bootveil .boothouse {
|
||||
position: static; /* .stage svg pins itself to inset:0 — not this one */
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
fill: var(--hp-accent);
|
||||
opacity: 0.85;
|
||||
animation: hp-boot-pulse 1.3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes hp-boot-pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.94); }
|
||||
50% { opacity: 0.9; transform: scale(1); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bootveil .boothouse {
|
||||
animation: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
.zoomctl {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
@@ -262,6 +300,15 @@ export const cardStyles = css`
|
||||
.stage.markup .op-hit:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
/* HP-1550-04: in the resize tool the wall handles own the hit test — the
|
||||
transparent .op-hit of a door at the midpoint of a wall used to sit ON
|
||||
TOP of the handle and made that wall ungrabbable for both rooms.
|
||||
Openings are not editable in this tool (they ride along with the wall),
|
||||
so their hit area goes fully inert; every other Plan tool is untouched. */
|
||||
.stage.markup.tool-resize .op-hit {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
.oplock {
|
||||
pointer-events: none; /* inert while editing; clickable in View (rule below) */
|
||||
position: absolute;
|
||||
@@ -866,12 +913,16 @@ export const cardStyles = css`
|
||||
width: 130px;
|
||||
}
|
||||
.dev.valonly {
|
||||
/* all satellite metrics from --dev-size, not --icon-size: the per-device
|
||||
size multiplier must scale the value plate with its marker — same bug
|
||||
class as the v1.51.3 glyph fix (pinned to base size, the multiplier
|
||||
grew nothing). */
|
||||
width: auto;
|
||||
min-width: var(--dev-size, var(--icon-size, 2.5cqw));
|
||||
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.16);
|
||||
padding: 0 calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.16);
|
||||
}
|
||||
.dev.valonly .valtext {
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
|
||||
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -879,11 +930,66 @@ export const cardStyles = css`
|
||||
/* v1.52.0: the RGB tint of the icon/border is gone — a lamp's colour
|
||||
lives ONLY in its glow spot (owner's rule). The ripple-color fallback
|
||||
keeps using the light colour; that is set inline via --ripple-color. */
|
||||
/* Owner's rule (2026-08-01, вариант «б»): «движение = разовая вспышка
|
||||
в момент обнаружения; cool-down не пульсирует; присутствие =
|
||||
статичное кольцо пока обитаемо». The yellow FILL stays reserved for
|
||||
«включено» — both sense states keep the neutral badge. Both rings
|
||||
are declared BEFORE .dev.alarm on purpose: equal specificity on the
|
||||
shared ::after means the later alarm rule wins if a device ever
|
||||
carries both — red beats yellow. */
|
||||
/* MOTION tripped: a short ONE-SHOT flash — three beats of the yellow
|
||||
ring (3 × 1.1s), then silence, even while the entity still reports
|
||||
'on' (that tail is the sensor's cool-down, not motion). The
|
||||
iteration count is FINITE, and the card itself drops the class
|
||||
~3.3s after the off→on transition (_senseTick/_stateClass in
|
||||
houseplan-card.ts); base opacity 0 keeps the ring invisible should
|
||||
the class outlive the animation by a frame. */
|
||||
.dev.senseflash::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.35);
|
||||
border: 2px solid var(--hp-on);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
animation: hp-sense 1.1s ease-in-out 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* HP-1543-02: a rapid off→on retrip must RESTART the flash. The card
|
||||
flips the .sf2 marker on every trip (_senseTick generation parity);
|
||||
hp-sense-b duplicates hp-sense exactly — switching the animation-name
|
||||
is what makes the browser abandon the old timeline and start a fresh
|
||||
one on the same pseudo-element (a same-name animation never restarts
|
||||
while the class stays on). */
|
||||
.dev.senseflash.sf2::after { animation-name: hp-sense-b; }
|
||||
/* OCCUPANCY/PRESENCE while 'on': a calm STATIC ring, no animation —
|
||||
«комната обитаема» is a state, not an event, so it must not blink.
|
||||
Brightness matches the reduced-motion variant of the flash. */
|
||||
.dev.sensehold::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.35);
|
||||
border: 2px solid var(--hp-on);
|
||||
border-radius: 50%;
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes hp-sense {
|
||||
0% { transform: scale(0.9); opacity: 0.5; }
|
||||
60% { transform: scale(1.12); opacity: 0.12; }
|
||||
100% { transform: scale(1.12); opacity: 0; }
|
||||
}
|
||||
/* identical twin of hp-sense — exists ONLY as the alternate animation
|
||||
identity for the retrip restart (HP-1543-02), keep the two in sync */
|
||||
@keyframes hp-sense-b {
|
||||
0% { transform: scale(0.9); opacity: 0.5; }
|
||||
60% { transform: scale(1.12); opacity: 0.12; }
|
||||
100% { transform: scale(1.12); opacity: 0; }
|
||||
}
|
||||
/* alarms pulse red over everything */
|
||||
.dev.alarm::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: calc(var(--icon-size, 2.5cqw) * -0.35);
|
||||
inset: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.35);
|
||||
border: 3px solid #f25a4a;
|
||||
border-radius: 50%;
|
||||
animation: hp-alarm 1s ease-out infinite;
|
||||
@@ -895,13 +1001,18 @@ export const cardStyles = css`
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dev.alarm::after { animation: none; opacity: 0.9; }
|
||||
/* the motion flash becomes a static ring for the same ~3.3s class
|
||||
window — consistent with alarm's treatment; sensehold is static
|
||||
by design already */
|
||||
.dev.senseflash::after,
|
||||
.dev.senseflash.sf2::after { animation: none; opacity: 0.4; } /* HP-1543-02: retrip re-arms the window, ring stays static */
|
||||
}
|
||||
.dev .newdot {
|
||||
position: absolute;
|
||||
top: calc(var(--icon-size, 2.5cqw) * -0.12);
|
||||
right: calc(var(--icon-size, 2.5cqw) * -0.12);
|
||||
width: calc(var(--icon-size, 2.5cqw) * 0.34);
|
||||
height: calc(var(--icon-size, 2.5cqw) * 0.34);
|
||||
top: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.12);
|
||||
right: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.12);
|
||||
width: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.34);
|
||||
height: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.34);
|
||||
border-radius: 50%;
|
||||
background: #f0301f;
|
||||
border: 2px solid var(--card-background-color, var(--hp-bg));
|
||||
@@ -993,14 +1104,14 @@ export const cardStyles = css`
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
|
||||
margin-left: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.1);
|
||||
background: var(--card-background-color, var(--hp-bg));
|
||||
border: 1px solid var(--hp-accent);
|
||||
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
|
||||
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
|
||||
border-radius: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.18);
|
||||
padding: 0 calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.14);
|
||||
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
|
||||
font-weight: 700;
|
||||
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
|
||||
line-height: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.68);
|
||||
color: var(--hp-txt);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
@@ -1010,14 +1121,14 @@ export const cardStyles = css`
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
|
||||
margin-left: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.1);
|
||||
background: var(--card-background-color, var(--hp-bg));
|
||||
border: 1px solid #4fc3f7;
|
||||
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
|
||||
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
|
||||
border-radius: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.18);
|
||||
padding: 0 calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.14);
|
||||
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
|
||||
font-weight: 700;
|
||||
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
|
||||
line-height: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.68);
|
||||
color: var(--hp-txt);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
@@ -1027,8 +1138,8 @@ export const cardStyles = css`
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-top: calc(var(--icon-size, 2.5cqw) * 0.05);
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.38);
|
||||
margin-top: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.05);
|
||||
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.38);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 3px rgba(0, 0, 0, 0.9), 0 0 2px rgba(0, 0, 0, 0.9);
|
||||
@@ -1233,10 +1344,190 @@ export const cardStyles = css`
|
||||
color: var(--hp-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
/* live vacuum: a round puck, no badge plate, soft pulse (docs/VACUUM.md) */
|
||||
.vacpuck {
|
||||
position: absolute;
|
||||
/* the base badge, but round and 20% smaller — the owner's wording:
|
||||
«иконка похожа на иконку базы, только круглая и чуть меньше» */
|
||||
--puck-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.8);
|
||||
width: var(--puck-size);
|
||||
height: var(--puck-size);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--hp-bg);
|
||||
border: 1px solid var(--hp-line);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
|
||||
color: var(--hp-txt);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 6;
|
||||
/* glide between sparse position updates; .jump disables it so the robot
|
||||
never appears to drive through walls after a data gap */
|
||||
transition: left 1.2s linear, top 1.2s linear;
|
||||
animation: vacpulse 2.2s ease-out infinite;
|
||||
}
|
||||
.vacpuck.jump { transition: none; }
|
||||
.vacpuck.stale { opacity: 0.45; animation: none; }
|
||||
.vacpuck ha-icon {
|
||||
--mdc-icon-size: calc(var(--puck-size) * 0.68);
|
||||
color: var(--hp-txt);
|
||||
/* same centering recipe as .dev ha-icon: without flex + line-height 0
|
||||
the glyph sits on its text baseline and appears to float around the
|
||||
circle (owner report 2026-07-31) */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
width: var(--mdc-icon-size);
|
||||
height: var(--mdc-icon-size);
|
||||
}
|
||||
@keyframes vacpulse {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--hp-accent) 45%, transparent); }
|
||||
70% { box-shadow: 0 0 0 12px transparent; }
|
||||
100% { box-shadow: 0 0 0 0 transparent; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vacpuck { animation: none; }
|
||||
}
|
||||
.vactrail {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
overflow: visible;
|
||||
}
|
||||
.vactrail polyline {
|
||||
fill: none;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
/* dark halo + light core: neutral, and one of the two always contrasts
|
||||
with whatever fill is underneath */
|
||||
.vactrail g.prev { opacity: 0.4; }
|
||||
.vactrail .case {
|
||||
stroke: rgba(0, 0, 0, 0.4);
|
||||
stroke-width: 2.25;
|
||||
}
|
||||
.vactrail .core {
|
||||
stroke: rgba(255, 255, 255, 0.82);
|
||||
stroke-width: 0.9;
|
||||
}
|
||||
.vacbox .vacbtns { display: flex; gap: 8px; margin: 6px 0; flex-wrap: wrap; }
|
||||
.vacfit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 12;
|
||||
overflow: visible;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
/* the devlayer is pointer-events: none and every child opts back in —
|
||||
without this line real clicks flew straight through the overlay
|
||||
(owner: «уголки не кликабельны»; synthetic smoke events bypass
|
||||
hit-testing, which is why they lied) */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.vacfit:active { cursor: grabbing; }
|
||||
.vacfit polygon {
|
||||
fill: color-mix(in srgb, var(--hp-accent) 16%, transparent);
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
stroke-dasharray: 6 4;
|
||||
}
|
||||
.vacfit text {
|
||||
fill: var(--hp-accent);
|
||||
font-size: 26px;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
/* room resize tool (docs/RESIZE.md) */
|
||||
/* wall handle: invisible finger-sized hit circle (HP-1550-04 hit priority
|
||||
kept), the visible glyph lives in the sibling .rszicon */
|
||||
.rszhandle {
|
||||
fill: transparent;
|
||||
stroke: none;
|
||||
pointer-events: all;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
.rszhandle:active { cursor: grabbing; }
|
||||
/* wall-with-arrows glyph: accent ink over a bg halo, readable on any plan */
|
||||
.rszicon { pointer-events: none; }
|
||||
.rszicon path {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.rszhalo { stroke: var(--hp-bg); stroke-width: 6; }
|
||||
.rszink { stroke: var(--hp-accent); stroke-width: 2; }
|
||||
.rszhandle:hover + .rszicon .rszink { stroke-width: 3; }
|
||||
/* corner (scale-frame) handles keep the classic filled circle */
|
||||
.rszcorner {
|
||||
fill: var(--hp-bg);
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.rszcorner:hover { fill: var(--hp-accent); }
|
||||
.rszcorner:active { cursor: nwse-resize; }
|
||||
.rszframe {
|
||||
fill: none;
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 6 5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: none;
|
||||
}
|
||||
.measurelabel.rszarea {
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.vacfitdot { fill: var(--hp-accent); pointer-events: none; }
|
||||
.vacfithandle {
|
||||
fill: var(--hp-bg);
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.vaccalbar {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: var(--hp-panel, #16212e);
|
||||
color: var(--hp-fg, #e7eef7);
|
||||
border: 1px solid var(--hp-accent);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
z-index: 60;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.candlist {
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--hp-line);
|
||||
/* A scrollable box is a flex item that HAPPILY collapses: inside the
|
||||
dialog body (a flex column) this list rendered its rows into a 1px
|
||||
sliver — the DOM had 26 candidates and the user saw nothing. In the
|
||||
binding dropdown it sits inside .droppanel (block context) and never
|
||||
showed the bug. Field report, 2026-07-30. */
|
||||
flex: 0 0 auto;
|
||||
min-height: 2.6em;
|
||||
}
|
||||
.cand {
|
||||
display: flex;
|
||||
|
||||
@@ -51,6 +51,20 @@ export interface Marker {
|
||||
description?: string | null;
|
||||
pdfs?: PdfRef[];
|
||||
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
|
||||
/** 'run' target: automation./script./scene. entity id. */
|
||||
tap_target?: string | null;
|
||||
/** Ask before toggle/run — accidental-tap guard (owner's spec). */
|
||||
tap_confirm?: boolean | null;
|
||||
/** live robot vacuum (docs/VACUUM.md); absent on non-vacuum markers */
|
||||
vacuum?: {
|
||||
live?: boolean | null;
|
||||
trail?: boolean | null; // legacy bool; trail_mode wins
|
||||
trail_mode?: 'never' | 'cleaning' | 'always' | null;
|
||||
room_highlight?: boolean | null;
|
||||
source?: string | null;
|
||||
calibration?: Record<string, number[]>;
|
||||
segment_map?: Record<string, string>;
|
||||
} | null;
|
||||
room_id?: string | null; // manual placement into a room WITHOUT an HA area (sub-area rooms)
|
||||
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | null; // how the device is drawn
|
||||
ripple_color?: string | null;
|
||||
|
||||
Executable
+328
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Live robot vacuums: coordinate math and integration adapters.
|
||||
*
|
||||
* Pure logic, no Lit — everything here is unit-tested directly. The renderer
|
||||
* consumes three things: a solved affine matrix (vacuum mm → plan canvas
|
||||
* units), normalised telemetry from whatever integration the user happens to
|
||||
* run, and a thinned trail. See docs/VACUUM.md for the approved contract.
|
||||
*/
|
||||
|
||||
export type Affine = [number, number, number, number, number, number];
|
||||
export type Pt = [number, number];
|
||||
|
||||
/** target = [a b; d e]·source + [c f] */
|
||||
export function applyAffine(m: Affine, x: number, y: number): Pt {
|
||||
return [m[0] * x + m[1] * y + m[2], m[3] * x + m[4] * y + m[5]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Least-squares affine over point pairs (≥3). Solves two independent 3-unknown
|
||||
* systems via normal equations; returns null for degenerate input (collinear
|
||||
* points make the normal matrix singular — the wizard asks for a spread).
|
||||
*/
|
||||
export function solveAffine(pairs: Array<[Pt, Pt]>): Affine | null {
|
||||
if (pairs.length < 3) return null;
|
||||
// normal matrix A^T A (3x3) and right-hand sides for tx and ty
|
||||
let sxx = 0, sxy = 0, sx = 0, syy = 0, sy = 0, n = 0;
|
||||
let bx0 = 0, bx1 = 0, bx2 = 0, by0 = 0, by1 = 0, by2 = 0;
|
||||
for (const [[x, y], [tx, ty]] of pairs) {
|
||||
if (![x, y, tx, ty].every(Number.isFinite)) return null;
|
||||
sxx += x * x; sxy += x * y; sx += x; syy += y * y; sy += y; n += 1;
|
||||
bx0 += x * tx; bx1 += y * tx; bx2 += tx;
|
||||
by0 += x * ty; by1 += y * ty; by2 += ty;
|
||||
}
|
||||
const A = [sxx, sxy, sx, sxy, syy, sy, sx, sy, n];
|
||||
const solve3 = (b: number[]): number[] | null => {
|
||||
// Cramer via explicit inverse of the symmetric 3x3
|
||||
const [a, b1, c, d, e, f, g, h, i] = A;
|
||||
const det = a * (e * i - f * h) - b1 * (d * i - f * g) + c * (d * h - e * g);
|
||||
if (!Number.isFinite(det) || Math.abs(det) < 1e-9) return null;
|
||||
const inv = [
|
||||
(e * i - f * h) / det, (c * h - b1 * i) / det, (b1 * f - c * e) / det,
|
||||
(f * g - d * i) / det, (a * i - c * g) / det, (c * d - a * f) / det,
|
||||
(d * h - e * g) / det, (b1 * g - a * h) / det, (a * e - b1 * d) / det,
|
||||
];
|
||||
return [
|
||||
inv[0] * b[0] + inv[1] * b[1] + inv[2] * b[2],
|
||||
inv[3] * b[0] + inv[4] * b[1] + inv[5] * b[2],
|
||||
inv[6] * b[0] + inv[7] * b[1] + inv[8] * b[2],
|
||||
];
|
||||
};
|
||||
const rx = solve3([bx0, bx1, bx2]);
|
||||
const ry = solve3([by0, by1, by2]);
|
||||
if (!rx || !ry) return null;
|
||||
const m: Affine = [rx[0], rx[1], rx[2], ry[0], ry[1], ry[2]];
|
||||
return m.every(Number.isFinite) ? m : null;
|
||||
}
|
||||
|
||||
/** Worst residual in target units — the wizard warns above a threshold. */
|
||||
export function affineResidual(m: Affine, pairs: Array<[Pt, Pt]>): number {
|
||||
let worst = 0;
|
||||
for (const [s, t] of pairs) {
|
||||
const p = applyAffine(m, s[0], s[1]);
|
||||
worst = Math.max(worst, Math.hypot(p[0] - t[0], p[1] - t[1]));
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
// ---------------- telemetry adapters ----------------
|
||||
|
||||
export interface VacRoom { id: string; name: string; cx: number; cy: number;
|
||||
x0?: number; y0?: number; x1?: number; y1?: number }
|
||||
export interface VacTelemetry {
|
||||
pos: { x: number; y: number; a: number | null } | null;
|
||||
path: Pt[] | null; // integration-provided full path, vacuum coords
|
||||
rooms: VacRoom[]; // for auto-calibration; empty when unknown
|
||||
mapId: string; // multi-floor robots: one calibration per map
|
||||
}
|
||||
|
||||
const num = (v: unknown): number | null => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Map-id normalisation contract, shared with the backend recorder
|
||||
* (custom_components/houseplan/trails.py: resolve_map_id). The FIRST value
|
||||
* that is not null/undefined wins — truthiness is wrong here, because a
|
||||
* zero-based `map_index: 0` is a perfectly valid first map and an empty
|
||||
* string is still an id. The backend used an `or`-chain and dropped the
|
||||
* zero, so server trails were stored under a key the renderer never looked
|
||||
* up (HP-1540-02).
|
||||
*/
|
||||
export function vacMapIdFromAttrs(attrs: Record<string, any>): string {
|
||||
return String(attrs.map_name ?? attrs.current_map ?? attrs.map_index ?? attrs.selected_map ?? 'default');
|
||||
}
|
||||
|
||||
/**
|
||||
* The card-side fallback half of that contract (HP-1541-01): when source
|
||||
* telemetry names no map ('default'), the vacuum entity's own selected_map
|
||||
* decides — under the SAME not-nullish rule as above. The old truthiness
|
||||
* check in _vacMapId turned `selected_map: 0` into 'default' while the
|
||||
* server recorder (trails.py resolve_map_id) stored the trail under '0', so
|
||||
* calibration and saved runs lived under a key the renderer never matched.
|
||||
*/
|
||||
export function vacMapIdWithFallback(teleMapId: string, selectedMap: unknown): string {
|
||||
if (teleMapId !== 'default') return teleMapId;
|
||||
return selectedMap != null ? String(selectedMap) : 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise the attribute zoo. One parser instead of per-brand classes: the
|
||||
* three Tier-A integrations (Xiaomi Cloud Map Extractor, Tasshack
|
||||
* dreame-vacuum, Valetudo camera) all descend from the map-card conventions
|
||||
* and differ only in field spellings.
|
||||
*/
|
||||
export function readVacTelemetry(attrs: Record<string, any> | null | undefined): VacTelemetry | null {
|
||||
if (!attrs) return null;
|
||||
const p = attrs.vacuum_position || attrs.robot_position || null;
|
||||
const pos = p && num(p.x) != null && num(p.y) != null
|
||||
? { x: num(p.x)!, y: num(p.y)!, a: num(p.a ?? p.angle ?? p.theta) }
|
||||
: null;
|
||||
// path: [{x,y},…] (Map Extractor) or [[x,y],…]
|
||||
let path: Pt[] | null = null;
|
||||
const rawPath = attrs.path?.points ?? attrs.path;
|
||||
if (Array.isArray(rawPath) && rawPath.length) {
|
||||
path = [];
|
||||
for (const q of rawPath) {
|
||||
const x = num(Array.isArray(q) ? q[0] : q?.x);
|
||||
const y = num(Array.isArray(q) ? q[1] : q?.y);
|
||||
if (x != null && y != null) path.push([x, y]);
|
||||
}
|
||||
if (!path.length) path = null;
|
||||
}
|
||||
// rooms: {id:{name,x0,y0,x1,y1}} | [{id,name,x0..}] | {id:{name,outline}}
|
||||
const rooms: VacRoom[] = [];
|
||||
const rawRooms = attrs.rooms;
|
||||
const entries: Array<[string, any]> = Array.isArray(rawRooms)
|
||||
? rawRooms.map((r: any, i: number) => [String(r?.id ?? i), r])
|
||||
: rawRooms && typeof rawRooms === 'object' ? Object.entries(rawRooms) : [];
|
||||
for (const [id, r] of entries) {
|
||||
if (!r || typeof r !== 'object') continue;
|
||||
const name = String(r.name ?? r.label ?? '').trim();
|
||||
let cx = num(r.cx ?? r.center?.x); let cy = num(r.cy ?? r.center?.y);
|
||||
if (cx == null || cy == null) {
|
||||
const x0 = num(r.x0), y0 = num(r.y0), x1 = num(r.x1), y1 = num(r.y1);
|
||||
if (x0 != null && y0 != null && x1 != null && y1 != null) {
|
||||
cx = (x0 + x1) / 2; cy = (y0 + y1) / 2;
|
||||
}
|
||||
}
|
||||
// Tasshack dreame-vacuum: the room centre is plain x/y (verified against
|
||||
// a live X50 Master; x/y sits within its own x0..x1 bbox)
|
||||
if (cx == null || cy == null) { cx = num(r.x); cy = num(r.y); }
|
||||
if (name && cx != null && cy != null) {
|
||||
const room: VacRoom = { id, name, cx, cy };
|
||||
const x0 = num(r.x0), y0 = num(r.y0), x1 = num(r.x1), y1 = num(r.y1);
|
||||
if (x0 != null && y0 != null && x1 != null && y1 != null) {
|
||||
room.x0 = Math.min(x0, x1); room.y0 = Math.min(y0, y1);
|
||||
room.x1 = Math.max(x0, x1); room.y1 = Math.max(y0, y1);
|
||||
}
|
||||
rooms.push(room);
|
||||
}
|
||||
}
|
||||
const mapId = vacMapIdFromAttrs(attrs);
|
||||
if (!pos && !rooms.length && !path) return null;
|
||||
return { pos, path, rooms, mapId };
|
||||
}
|
||||
|
||||
/** Attribute sets that mark an entity as a live-position source. */
|
||||
export function isVacSourceState(st: { attributes?: Record<string, any> } | null | undefined): boolean {
|
||||
const a = st?.attributes;
|
||||
return !!(a && (a.vacuum_position || a.robot_position));
|
||||
}
|
||||
|
||||
// ---------------- auto-calibration by rooms ----------------
|
||||
|
||||
const canonName = (s: string): string => s.toLowerCase().replace(/[\s_\-.,]+/g, '');
|
||||
|
||||
/**
|
||||
* Match the robot's room list against plan rooms by name and solve the
|
||||
* transform over centroids. Room centroids are coarse anchors, which is fine:
|
||||
* the residual check below rejects a bad fit, and the user always sees a live
|
||||
* preview before accepting.
|
||||
*/
|
||||
export function autoCalibrate(
|
||||
vacRooms: VacRoom[],
|
||||
planRooms: Array<{ name: string; cx: number; cy: number }>,
|
||||
): { matrix: Affine; matched: string[]; residual: number } | null {
|
||||
const byName = new Map(planRooms.map((r) => [canonName(r.name), r]));
|
||||
const pairs: Array<[Pt, Pt]> = [];
|
||||
const matched: string[] = [];
|
||||
for (const vr of vacRooms) {
|
||||
const pr = byName.get(canonName(vr.name));
|
||||
if (!pr) continue;
|
||||
pairs.push([[vr.cx, vr.cy], [pr.cx, pr.cy]]);
|
||||
matched.push(vr.name);
|
||||
}
|
||||
if (pairs.length < 3) return null;
|
||||
const matrix = solveAffine(pairs);
|
||||
if (!matrix) return null;
|
||||
return { matrix, matched, residual: affineResidual(matrix, pairs) };
|
||||
}
|
||||
|
||||
// ---------------- trail ----------------
|
||||
|
||||
export const TRAIL_MAX = 600;
|
||||
export const VAC_TELEPORT_GAP_MS = 10000;
|
||||
export const VAC_STALE_MS = 60000;
|
||||
export const VAC_TRAIL_LINGER_MS = 10 * 60000;
|
||||
|
||||
/** Ramer–Douglas–Peucker; keeps ends, drops points under eps deviation. */
|
||||
export function thinPath(pts: Pt[], eps: number): Pt[] {
|
||||
if (pts.length < 3) return pts.slice();
|
||||
const keep = new Uint8Array(pts.length);
|
||||
keep[0] = keep[pts.length - 1] = 1;
|
||||
const stack: Array<[number, number]> = [[0, pts.length - 1]];
|
||||
while (stack.length) {
|
||||
const [a, b] = stack.pop()!;
|
||||
const [ax, ay] = pts[a]; const [bx, by] = pts[b];
|
||||
const dx = bx - ax, dy = by - ay;
|
||||
const len = Math.hypot(dx, dy) || 1e-9;
|
||||
let worst = 0, wi = -1;
|
||||
for (let i = a + 1; i < b; i++) {
|
||||
const d = Math.abs((pts[i][0] - ax) * dy - (pts[i][1] - ay) * dx) / len;
|
||||
if (d > worst) { worst = d; wi = i; }
|
||||
}
|
||||
if (wi > 0 && worst > eps) {
|
||||
keep[wi] = 1;
|
||||
stack.push([a, wi], [wi, b]);
|
||||
}
|
||||
}
|
||||
const out: Pt[] = [];
|
||||
for (let i = 0; i < pts.length; i++) if (keep[i]) out.push(pts[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Append a point; over the cap → thin, and if thinning was not enough, decimate. */
|
||||
export function pushTrailPoint(buf: Pt[], p: Pt, epsHint: number): Pt[] {
|
||||
const last = buf[buf.length - 1];
|
||||
if (last && last[0] === p[0] && last[1] === p[1]) return buf;
|
||||
buf.push(p);
|
||||
if (buf.length <= TRAIL_MAX) return buf;
|
||||
let thinned = thinPath(buf, epsHint);
|
||||
if (thinned.length > TRAIL_MAX) thinned = thinned.filter((_, i) => i % 2 === 0 || i === thinned.length - 1);
|
||||
return thinned;
|
||||
}
|
||||
|
||||
/** true → the robot is actively driving (a puck should exist). */
|
||||
export function isVacMoving(state: string | undefined): boolean {
|
||||
return state === 'cleaning' || state === 'returning' || state === 'on';
|
||||
}
|
||||
|
||||
// ---------------- the fit panel (drag + corner-stretch calibration) ----------------
|
||||
|
||||
/** What the user manipulates; folds into the same stored 6-number matrix. */
|
||||
export interface FitParams {
|
||||
ox: number; oy: number; // translation, canvas units
|
||||
s: number; // uniform scale, canvas units per robot unit
|
||||
rot: 0 | 90 | 180 | 270; // whole-quarter rotation
|
||||
mir: boolean; // mirror (robots' Y usually grows the other way)
|
||||
}
|
||||
|
||||
const ROT_CS: Record<number, [number, number]> = { 0: [1, 0], 90: [0, 1], 180: [-1, 0], 270: [0, -1] };
|
||||
|
||||
/** target = S · R(rot) · diag(mir ? −1 : 1, 1) · source + (ox, oy) */
|
||||
export function fitMatrix(p: FitParams): Affine {
|
||||
const [c, s_] = ROT_CS[p.rot] || [1, 0];
|
||||
const mx = p.mir ? -1 : 1;
|
||||
return [p.s * c * mx, -p.s * s_, p.ox, p.s * s_ * mx, p.s * c, p.oy];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompose a stored matrix back into panel params. Rotation snaps to the
|
||||
* nearest quarter — a legacy 3-point matrix reopens as an editable start,
|
||||
* not verbatim, and that is fine: the ghost shows the result live.
|
||||
*/
|
||||
export function fitFromMatrix(m: Affine): FitParams | null {
|
||||
const det = m[0] * m[4] - m[1] * m[3];
|
||||
if (!Number.isFinite(det) || Math.abs(det) < 1e-12) return null;
|
||||
const mir = det < 0;
|
||||
const s = Math.sqrt(Math.abs(det));
|
||||
// the second column (b, e) = S·(−sin, cos) is mirror-free
|
||||
let ang = Math.atan2(-m[1], m[4]) * 180 / Math.PI;
|
||||
ang = ((Math.round(ang / 90) * 90) % 360 + 360) % 360;
|
||||
return { ox: m[2], oy: m[5], s, rot: ang as FitParams['rot'], mir };
|
||||
}
|
||||
|
||||
/** A sane opening position: the robot map centred over the plan at 60% size. */
|
||||
export function initialFit(
|
||||
rooms: VacRoom[],
|
||||
vb: [number, number, number, number],
|
||||
): FitParams {
|
||||
const bx: number[] = [], by: number[] = [];
|
||||
for (const r of rooms) {
|
||||
if (r.x0 != null) { bx.push(r.x0, r.x1!); by.push(r.y0!, r.y1!); }
|
||||
else { bx.push(r.cx); by.push(r.cy); }
|
||||
}
|
||||
// no rooms at all: an arbitrary honest guess the user will drag anyway
|
||||
if (!bx.length) return { ox: vb[0] + vb[2] / 2, oy: vb[1] + vb[3] / 2, s: vb[2] / 10000, rot: 0, mir: true };
|
||||
const minX = Math.min(...bx), maxX = Math.max(...bx);
|
||||
const minY = Math.min(...by), maxY = Math.max(...by);
|
||||
const span = Math.max(maxX - minX, maxY - minY) || 1;
|
||||
const s = (Math.min(vb[2], vb[3]) * 0.6) / span;
|
||||
// mirror on by default: every robot map seen so far has Y flipped vs screen
|
||||
const p: FitParams = { ox: 0, oy: 0, s, rot: 0, mir: true };
|
||||
const m = fitMatrix(p);
|
||||
const [ccx, ccy] = applyAffine(m, (minX + maxX) / 2, (minY + maxY) / 2);
|
||||
p.ox = vb[0] + vb[2] / 2 - ccx;
|
||||
p.oy = vb[1] + vb[3] / 2 - ccy;
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Re-anchor params so the source point (sx, sy) stays at the same target spot. */
|
||||
export function reanchorFit(p: FitParams, prev: FitParams, sx: number, sy: number): FitParams {
|
||||
const [px, py] = applyAffine(fitMatrix(prev), sx, sy);
|
||||
const trial = fitMatrix({ ...p, ox: 0, oy: 0 });
|
||||
const [qx, qy] = applyAffine(trial, sx, sy);
|
||||
return { ...p, ox: px - qx, oy: py - qy };
|
||||
}
|
||||
|
||||
export type VacTrailMode = 'never' | 'cleaning' | 'always';
|
||||
|
||||
/** marker.vacuum → display mode; legacy bool maps in (false = never). */
|
||||
export function vacTrailMode(v: { trail?: boolean | null; trail_mode?: string | null } | null | undefined): VacTrailMode {
|
||||
const m = v?.trail_mode;
|
||||
if (m === 'never' || m === 'cleaning' || m === 'always') return m;
|
||||
if (v?.trail === false) return 'never';
|
||||
return 'cleaning';
|
||||
}
|
||||
@@ -25,3 +25,11 @@ test('i18n: placeholders match between languages', () => {
|
||||
assert.equal(ph(en[k]), ph(ru[k]), `placeholder mismatch in ${k}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('vac toasts never mention the removed point calibration (HP-1540-06)', () => {
|
||||
for (const [lang, d] of [['en', en], ['ru', ru]]) {
|
||||
for (const k of ['vac.autocal_no_rooms', 'vac.autocal_no_match', 'vac.autocal_res_warn']) {
|
||||
assert.ok(!/point|точк/i.test(d[k]), `${lang}:${k} still points at point calibration`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+26
-3
@@ -15,7 +15,7 @@ import {
|
||||
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
|
||||
interiorPoint,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility } from '../test-build/logic.js';
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS } from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
} from '../test-build/rules.js';
|
||||
@@ -190,8 +190,13 @@ test('tap action: defaults — info everywhere except pure lights (v1.39.0)', ()
|
||||
test('tap action: card-wide toggle only touches safe domains', () => {
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'light'), 'toggle');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'switch'), 'toggle');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover'), 'info'); // garage stays shut
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'valve'), 'info');
|
||||
// covers/valves joined the safe set for curtains (owner, 2026-07-29)…
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'curtain'), 'toggle');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'valve'), 'toggle');
|
||||
// …but the garage door is a cover too, and it STAYS SHUT on a default tap
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'garage'), 'info');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'gate'), 'info');
|
||||
assert.equal(resolveTapAction('toggle', null, 'cover', 'garage'), 'toggle', 'explicit stays a conscious choice');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'sensor'), 'info');
|
||||
});
|
||||
|
||||
@@ -936,3 +941,21 @@ test('poleOfInaccessibility: the VISUAL centre, not just any interior point', ()
|
||||
assert.ok(q[1] < 8, 'in the thick slab (y < 8), not down the column: ' + q);
|
||||
assert.ok(Math.abs(q[0] - 11.3) < 2.5, 'near the centroid x within the slab: ' + q);
|
||||
});
|
||||
|
||||
|
||||
test('tap action run: explicit-only, with runnable targets (owner spec 2026-07-29)', () => {
|
||||
// 'run' resolves only as an explicit per-marker action
|
||||
assert.equal(resolveTapAction('run', undefined, 'switch'), 'run');
|
||||
assert.equal(resolveTapAction(null, 'run', 'switch'), 'info', 'never a card-wide default');
|
||||
// runnable domains map to their services; anything else is not runnable
|
||||
assert.deepEqual(runServiceFor('automation.morning'), { domain: 'automation', service: 'trigger' });
|
||||
assert.deepEqual(runServiceFor('script.curtains'), { domain: 'script', service: 'turn_on' });
|
||||
assert.deepEqual(runServiceFor('scene.movie'), { domain: 'scene', service: 'turn_on' });
|
||||
assert.equal(runServiceFor('light.lamp'), null);
|
||||
assert.equal(runServiceFor(''), null);
|
||||
// covers and valves joined the card-wide toggle domains
|
||||
assert.ok(TOGGLE_SAFE_DOMAINS.has('cover') && TOGGLE_SAFE_DOMAINS.has('valve'));
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'shutter'), 'toggle');
|
||||
// locks and alarms stay forbidden, run or not
|
||||
assert.equal(resolveTapAction('toggle', undefined, 'lock'), 'info');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// Room resize geometry (docs/RESIZE.md): every «упор» and the T-junction
|
||||
// vertex insertion are pinned here numerically.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
edgeNormal, movePolyEdge, sharedSpansWith, shiftSharedSpans, simplifyPoly,
|
||||
polyIsSimple, minParallelClearance, planEdgeDrag, applyEdgeDrag,
|
||||
validateEdgeDrag, clampEdgeDrag, applyRoomScale, validateRoomScale,
|
||||
clampRoomScale, areaM2, formatArea, MIN_ROOM_CM,
|
||||
} from '../test-build/resize.js';
|
||||
import { roomPoly } from '../test-build/logic.js';
|
||||
|
||||
const OPTS = { minDim: 25, eps: 0.5 }; // 25 units ≈ 30 cm at cell_cm=5, pitch 1000/240
|
||||
const STEP = 5;
|
||||
|
||||
// A: 300×300 square, right edge is index 1 ((400,100)→(400,400))
|
||||
const A = () => ({ id: 'A', poly: [[100, 100], [400, 100], [400, 400], [100, 400]] });
|
||||
// R: full-height neighbour to the right — the ENTIRE wall x=400 is shared
|
||||
const R = () => ({ id: 'R', poly: [[400, 100], [700, 100], [700, 400], [400, 400]] });
|
||||
// B: shorter neighbour to the right — T-junction (covers y 100..300 only)
|
||||
const B = () => ({ id: 'B', poly: [[400, 100], [700, 100], [700, 300], [400, 300]] });
|
||||
|
||||
const closeTo = (got, want, tol = 1e-6) =>
|
||||
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
|
||||
const polyEq = (got, want, tol = 1e-6) => {
|
||||
assert.equal(got.length, want.length, `vertex count: ${JSON.stringify(got)}`);
|
||||
for (let i = 0; i < want.length; i++) {
|
||||
closeTo(got[i][0], want[i][0], tol);
|
||||
closeTo(got[i][1], want[i][1], tol);
|
||||
}
|
||||
};
|
||||
|
||||
test('edgeNormal points outward for both windings', () => {
|
||||
const a = A();
|
||||
assert.deepEqual(edgeNormal(a.poly, 1).map((v) => Math.round(v) + 0), [1, 0]); // right wall → +x
|
||||
assert.deepEqual(edgeNormal(a.poly, 3).map((v) => Math.round(v) + 0), [-1, 0]); // left wall → −x
|
||||
const ccw = [...a.poly].reverse(); // reversed winding, same square
|
||||
const n = edgeNormal(ccw, ccw.findIndex((p) => p[0] === 400 && p[1] === 400));
|
||||
assert.deepEqual(n.map((v) => Math.round(v) + 0), [1, 0]);
|
||||
});
|
||||
|
||||
test('movePolyEdge translates BOTH edge endpoints along the normal', () => {
|
||||
polyEq(movePolyEdge(A().poly, 1, 50), [[100, 100], [450, 100], [450, 400], [100, 400]]);
|
||||
polyEq(movePolyEdge(A().poly, 1, -50), [[100, 100], [350, 100], [350, 400], [100, 400]]);
|
||||
});
|
||||
|
||||
test('legacy x/y/w/h rectangles resize through roomPoly', () => {
|
||||
const poly = roomPoly({ x: 100, y: 100, w: 300, h: 300 });
|
||||
polyEq(movePolyEdge(poly, 1, 50), [[100, 100], [450, 100], [450, 400], [100, 400]]);
|
||||
});
|
||||
|
||||
test('full shared wall: the neighbour moves synchronously, no gap by construction', () => {
|
||||
const rooms = [A(), R()];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
const res = applyEdgeDrag(rooms, [], plan, 50, OPTS.eps);
|
||||
polyEq(res.polys.A, [[100, 100], [450, 100], [450, 400], [100, 400]]);
|
||||
polyEq(res.polys.R, [[450, 100], [700, 100], [700, 400], [450, 400]]); // R shrank, walls still coincide
|
||||
});
|
||||
|
||||
test('T-junction: only the coinciding stretch of the neighbour moves, vertices are inserted', () => {
|
||||
const rooms = [A(), B()];
|
||||
// drag B's left wall (edge 3: (400,300)→(400,100)) 50 units INTO A
|
||||
const plan = planEdgeDrag(rooms, 'B', 3);
|
||||
assert.deepEqual(plan.n.map((v) => Math.round(v) + 0), [-1, 0]);
|
||||
const res = applyEdgeDrag(rooms, [], plan, 50, OPTS.eps);
|
||||
polyEq(res.polys.B, [[350, 100], [700, 100], [700, 300], [350, 300]]);
|
||||
// A becomes L-shaped: the shared stretch (y 100..300) caves in, the rest stays
|
||||
polyEq(res.polys.A, [[100, 100], [350, 100], [350, 300], [400, 300], [400, 400], [100, 400]]);
|
||||
});
|
||||
|
||||
test('stop: own room minimum size (~30 cm)', () => {
|
||||
const rooms = [A()];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -280, OPTS), false); // width 20 < 25
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -275, OPTS), true); // width 25 — the floor
|
||||
closeTo(clampEdgeDrag(rooms, [], plan, -280, STEP, OPTS), -275);
|
||||
});
|
||||
|
||||
test('stop: the shrinking neighbour keeps its minimum size too', () => {
|
||||
const rooms = [A(), R()];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, 290, OPTS), false); // R would be 10 wide
|
||||
closeTo(clampEdgeDrag(rooms, [], plan, 290, STEP, OPTS), 275); // R stays 25
|
||||
});
|
||||
|
||||
test('stop: a growing wall may touch a foreign room but never overlap it', () => {
|
||||
const F = { id: 'F', poly: [[500, 100], [700, 100], [700, 400], [500, 400]] };
|
||||
const rooms = [A(), F];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, 150, OPTS), false); // crosses F
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, 100, OPTS), true); // touching = legal shared wall
|
||||
closeTo(clampEdgeDrag(rooms, [], plan, 150, STEP, OPTS), 100);
|
||||
});
|
||||
|
||||
test('stop: islands (islandsOf) block the wall, including a jump fully past them', () => {
|
||||
const P = { id: 'P', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] };
|
||||
const I = { id: 'I', poly: [[250, 250], [350, 250], [350, 350], [250, 350]] };
|
||||
const rooms = [P, I];
|
||||
const plan = planEdgeDrag(rooms, 'P', 1); // right wall of the parent
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -200, OPTS), false); // wall at 300 cuts the island
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -280, OPTS), false); // wall at 220 — island fully outside (no edge crossing!)
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -155, OPTS), false); // wall at 345 crosses the island
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -150, OPTS), true); // wall at 350 — flush with the island is a touch, legal
|
||||
closeTo(clampEdgeDrag(rooms, [], plan, -280, STEP, OPTS), -150);
|
||||
});
|
||||
|
||||
test('openings: a door ON the moving wall travels with it', () => {
|
||||
const rooms = [A(), R()];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
const ops = [{ id: 'o1', x: 400, y: 200, length: 60 }];
|
||||
const res = applyEdgeDrag(rooms, ops, plan, 50, OPTS.eps);
|
||||
assert.deepEqual(res.openings.o1, [450, 200]);
|
||||
assert.equal(validateEdgeDrag(rooms, ops, plan, 50, OPTS), true);
|
||||
});
|
||||
|
||||
test('stop: a side wall cannot get too short for its opening (own room)', () => {
|
||||
const rooms = [A()];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
const ops = [{ id: 'o2', x: 350, y: 100, length: 80 }]; // top wall, spans x 310..390
|
||||
assert.equal(validateEdgeDrag(rooms, ops, plan, -50, OPTS), false); // top wall ends at 350 < 390
|
||||
assert.equal(validateEdgeDrag(rooms, ops, plan, -10, OPTS), true); // ends exactly at 390
|
||||
closeTo(clampEdgeDrag(rooms, ops, plan, -50, STEP, OPTS), -10);
|
||||
});
|
||||
|
||||
test('stop: the neighbour’s opening anchors the drag too', () => {
|
||||
const rooms = [A(), R()];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
const ops = [{ id: 'o3', x: 460, y: 100, length: 40 }]; // R’s top wall, spans x 440..480
|
||||
// the wall corner may not land INSIDE the door: at d=50 the joint (x=450)
|
||||
// would sit in the middle of the opening — that is the «упор»
|
||||
assert.equal(validateEdgeDrag(rooms, ops, plan, 50, OPTS), false);
|
||||
assert.equal(validateEdgeDrag(rooms, ops, plan, 40, OPTS), true); // joint exactly at the door edge
|
||||
closeTo(clampEdgeDrag(rooms, ops, plan, 50, STEP, OPTS), 40);
|
||||
// fully past the door the opening sits on the GROWN room's wall — the
|
||||
// composite wall y=100 never shortens, so this is legal by construction
|
||||
assert.equal(validateEdgeDrag(rooms, ops, plan, 100, OPTS), true);
|
||||
});
|
||||
|
||||
test('scale: all vertices scale proportionally about the fixed corner', () => {
|
||||
const res = applyRoomScale(A(), [], [], [100, 100], 1.5, OPTS.eps);
|
||||
polyEq(res.poly, [[100, 100], [550, 100], [550, 550], [100, 550]]);
|
||||
});
|
||||
|
||||
test('scale stops: minimum size and the neighbour as a hard wall', () => {
|
||||
const F = { id: 'F', poly: [[500, 100], [700, 100], [700, 400], [500, 400]] };
|
||||
const rooms = [A(), F];
|
||||
assert.equal(validateRoomScale(rooms, [], 'A', [100, 100], 0.05, OPTS), false); // 15 < 25
|
||||
const kMin = clampRoomScale(rooms, [], 'A', [100, 100], 0.05, OPTS);
|
||||
closeTo(kMin * 300, 25, 0.5); // clamped at the 30 cm floor
|
||||
assert.equal(validateRoomScale(rooms, [], 'A', [100, 100], 2, OPTS), false); // overlaps F
|
||||
const kMax = clampRoomScale(rooms, [], 'A', [100, 100], 2, OPTS);
|
||||
closeTo(kMax, 400 / 300, 1e-3); // right wall lands exactly on F
|
||||
});
|
||||
|
||||
test('scale never drags the neighbour; a SHARED opening stays with the neighbour wall', () => {
|
||||
const rooms = [A(), R()];
|
||||
const shared = { id: 'os', x: 400, y: 200, length: 60 }; // on the shared wall
|
||||
const own = { id: 'oo', x: 100, y: 200, length: 60 }; // on A’s left wall only
|
||||
const res = applyRoomScale(A(), [shared, own], [R().poly], [400, 400], 0.5, OPTS.eps);
|
||||
assert.equal(res.openings.os, undefined); // stays put
|
||||
assert.deepEqual(res.openings.oo, [250, 300]); // follows the transform
|
||||
assert.equal(validateRoomScale(rooms, [shared], 'A', [400, 400], 0.5, OPTS), true);
|
||||
});
|
||||
|
||||
test('scale stop: an exclusive opening must still fit', () => {
|
||||
const rooms = [A()];
|
||||
const ops = [{ id: 'o4', x: 250, y: 100, length: 200 }]; // top wall, needs 200 units
|
||||
assert.equal(validateRoomScale(rooms, ops, 'A', [100, 100], 0.5, OPTS), false); // wall 150 < opening 200
|
||||
const k = clampRoomScale(rooms, ops, 'A', [100, 100], 0.5, OPTS);
|
||||
// the opening centre scales too: it fits while 100·(1−1.5k) ≤ ε, i.e. k ≥ 0.66
|
||||
closeTo(k, 0.66, 1e-3);
|
||||
});
|
||||
|
||||
test('shared spans + shiftSharedSpans invariants', () => {
|
||||
const spans = sharedSpansWith(B().poly, [400, 100], [400, 400], OPTS.eps);
|
||||
assert.equal(spans.length, 1);
|
||||
polyEq([spans[0][0], spans[0][1]].sort((p, q) => p[1] - q[1]), [[400, 100], [400, 300]]);
|
||||
assert.equal(shiftSharedSpans(A().poly, [900, 100], [900, 400], [10, 0], OPTS.eps), null); // nothing coincides
|
||||
});
|
||||
|
||||
test('simplifyPoly drops collinear leftovers, polyIsSimple flags a bowtie', () => {
|
||||
polyEq(simplifyPoly([[0, 0], [50, 0], [100, 0], [100, 100], [0, 100]]), [[0, 0], [100, 0], [100, 100], [0, 100]]);
|
||||
assert.equal(polyIsSimple([[0, 0], [100, 100], [100, 0], [0, 100]]), false);
|
||||
assert.equal(polyIsSimple(A().poly), true);
|
||||
});
|
||||
|
||||
test('minParallelClearance: the opposite-wall distance', () => {
|
||||
closeTo(minParallelClearance(A().poly, [[[400, 100], [400, 400]]], OPTS.eps), 300);
|
||||
// L-shape: only walls with an OVERLAPPING projection count — the x=80 wall
|
||||
// spans y 60..200 and casts no shadow on the y 0..60 span, so the opposite
|
||||
// wall is x=0 at distance 200 (the x=80 obstruction is the simplicity stop)
|
||||
const L = [[0, 0], [200, 0], [200, 60], [80, 60], [80, 200], [0, 200]];
|
||||
closeTo(minParallelClearance(L, [[[200, 0], [200, 60]]], OPTS.eps), 200);
|
||||
});
|
||||
|
||||
test('live numbers: areaM2 and formatArea', () => {
|
||||
const pitch = 1000 / 240;
|
||||
const poly = [[0, 0], [100, 0], [100, 100], [0, 100]]; // 24 cells → 120 cm a side
|
||||
closeTo(areaM2(poly, pitch, 5), 1.44, 1e-9);
|
||||
assert.equal(formatArea(1.44, false), '1.4 m²');
|
||||
assert.equal(formatArea(1.44, true), '16 ft²');
|
||||
assert.equal(MIN_ROOM_CM, 30);
|
||||
});
|
||||
|
||||
test('zero drag is always valid and clamps to zero', () => {
|
||||
const rooms = [A()];
|
||||
const plan = planEdgeDrag(rooms, 'A', 1);
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, 0, OPTS), true);
|
||||
assert.equal(clampEdgeDrag(rooms, [], plan, 0, STEP, OPTS), 0);
|
||||
});
|
||||
|
||||
// ================= HP-1550-02 (the v1.55.0 audit): the 30 cm floor must be
|
||||
// orientation-independent — triangles have no parallel opposite wall and a
|
||||
// rotated rectangle hides its true short side from the axis-aligned bbox.
|
||||
|
||||
test('HP-1550-02: triangle — the min-size stop holds without a parallel opposite wall', () => {
|
||||
// base 300 wide, apex 300 above it; drag the base toward the apex
|
||||
const T = { id: 'T', poly: [[0, 0], [300, 0], [150, 300]] };
|
||||
const rooms = [T];
|
||||
const plan = planEdgeDrag(rooms, 'T', 0);
|
||||
assert.deepEqual(plan.n.map((v) => Math.round(v) + 0), [0, -1]); // outward = away from the apex
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -295, OPTS), false); // height 5 < 25
|
||||
const d = clampEdgeDrag(rooms, [], plan, -295, STEP, OPTS);
|
||||
const height = 300 - Math.abs(d);
|
||||
assert.ok(height >= OPTS.minDim - OPTS.eps, `triangle squeezed to height ${height}`);
|
||||
});
|
||||
|
||||
test('HP-1550-02: rotated rectangle — scale stops at the TRUE short side, not the bbox', () => {
|
||||
// 500×100 rectangle rotated 45°: its axis-aligned bbox is ≈424×424, so the
|
||||
// bbox measure let k=0.1 slip through while the real short side became 10
|
||||
const c45 = Math.SQRT1_2;
|
||||
const rot = ([x, y]) => [x * c45 - y * c45, x * c45 + y * c45];
|
||||
const P = [[0, 0], [500, 0], [500, 100], [0, 100]].map(rot);
|
||||
const rooms = [{ id: 'S', poly: P }];
|
||||
const fixed = [P[0][0], P[0][1]];
|
||||
assert.equal(validateRoomScale(rooms, [], 'S', fixed, 0.1, OPTS), false); // side 10 < 25
|
||||
const k = clampRoomScale(rooms, [], 'S', fixed, 0.1, OPTS);
|
||||
assert.ok(k * 100 >= OPTS.minDim - OPTS.eps, `short side shrank to ${k * 100}`);
|
||||
});
|
||||
|
||||
test('HP-1550-02: concave room — a non-parallel obstacle stops the drag', () => {
|
||||
// a "roof" vertex dips into the room at (200,120); the bottom wall dragged up
|
||||
// has NO parallel opposite wall, yet must stop ~30 cm short of the dip
|
||||
const C = { id: 'C', poly: [[0, 0], [400, 0], [400, 200], [200, 120], [0, 200]] };
|
||||
const rooms = [C];
|
||||
const plan = planEdgeDrag(rooms, 'C', 0);
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -115, OPTS), false); // 5 from the dip
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -95, OPTS), true); // 25 — the floor
|
||||
closeTo(clampEdgeDrag(rooms, [], plan, -115, STEP, OPTS), -95);
|
||||
});
|
||||
|
||||
test('HP-1550-02: an already-thin triangle may improve but never worsen', () => {
|
||||
const T = { id: 'T', poly: [[0, 0], [300, 0], [150, 20]] }; // height 20 < 25 already
|
||||
const rooms = [T];
|
||||
const plan = planEdgeDrag(rooms, 'T', 0);
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, -5, OPTS), false); // 20 → 15: worse
|
||||
assert.equal(validateEdgeDrag(rooms, [], plan, 50, OPTS), true); // 20 → 70: better
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { solveAffine, applyAffine, affineResidual, readVacTelemetry, autoCalibrate, thinPath, pushTrailPoint, isVacMoving, isVacSourceState, vacMapIdFromAttrs, vacMapIdWithFallback } from '../test-build/vacuum.js';
|
||||
|
||||
test('solveAffine recovers rotation+scale+mirror+offset exactly', () => {
|
||||
// target = mirror-X, rotate 90°, scale 0.02, offset (300, 400)
|
||||
const f = ([x, y]) => [300 + 0.02 * y, 400 + 0.02 * x];
|
||||
const src = [[1000, 2000], [8000, 2500], [3000, 9000], [7000, 7000]];
|
||||
const m = solveAffine(src.map((p) => [p, f(p)]));
|
||||
assert.ok(m);
|
||||
for (const p of [[5000, 5000], [0, 0], [12000, 3000]]) {
|
||||
const got = applyAffine(m, p[0], p[1]);
|
||||
const want = f(p);
|
||||
assert.ok(Math.hypot(got[0] - want[0], got[1] - want[1]) < 1e-6, String(p));
|
||||
}
|
||||
assert.ok(affineResidual(m, src.map((p) => [p, f(p)])) < 1e-6);
|
||||
});
|
||||
|
||||
test('solveAffine rejects degenerate input', () => {
|
||||
assert.equal(solveAffine([[[0, 0], [0, 0]], [[1, 1], [1, 1]]]), null); // 2 pairs
|
||||
// collinear
|
||||
assert.equal(solveAffine([[[0, 0], [0, 0]], [[1, 0], [1, 0]], [[2, 0], [2, 0]]]), null);
|
||||
assert.equal(solveAffine([[[0, NaN], [0, 0]], [[1, 0], [1, 0]], [[2, 3], [2, 0]]]), null);
|
||||
});
|
||||
|
||||
test('readVacTelemetry: Map Extractor shape', () => {
|
||||
const t = readVacTelemetry({
|
||||
vacuum_position: { x: 25500, y: 24800, a: 271 },
|
||||
path: [{ x: 25000, y: 24000 }, { x: 25100, y: 24100 }],
|
||||
rooms: { 16: { name: 'Kitchen', x0: 20000, y0: 20000, x1: 26000, y1: 25000 } },
|
||||
map_name: '0',
|
||||
});
|
||||
assert.deepEqual(t.pos, { x: 25500, y: 24800, a: 271 });
|
||||
assert.deepEqual(t.path, [[25000, 24000], [25100, 24100]]);
|
||||
assert.equal(t.rooms[0].name, 'Kitchen');
|
||||
assert.equal(t.rooms[0].cx, 23000);
|
||||
assert.equal(t.mapId, '0');
|
||||
});
|
||||
|
||||
test('readVacTelemetry: Valetudo/Tasshack shapes + junk safety', () => {
|
||||
const t = readVacTelemetry({ robot_position: { x: '120', y: '340', angle: '90' }, rooms: [{ id: 7, name: 'Спальня', cx: 10, cy: 20 }] });
|
||||
assert.deepEqual(t.pos, { x: 120, y: 340, a: 90 });
|
||||
assert.equal(t.rooms[0].id, '7');
|
||||
assert.equal(readVacTelemetry({ vacuum_position: { x: 'nope', y: 1 } }), null);
|
||||
assert.equal(readVacTelemetry({}), null);
|
||||
assert.equal(readVacTelemetry(null), null);
|
||||
assert.ok(isVacSourceState({ attributes: { vacuum_position: { x: 1, y: 2 } } }));
|
||||
assert.ok(!isVacSourceState({ attributes: { battery: 1 } }));
|
||||
});
|
||||
|
||||
test('readVacTelemetry: Tasshack room centres come as plain x/y', () => {
|
||||
// shape captured from a live Dreame X50 Master (dacha, 2026-07-31)
|
||||
const t = readVacTelemetry({
|
||||
vacuum_position: { x: 1399, y: -55, a: 181 },
|
||||
rooms: { 2: { room_id: 2, name: 'Кладовка', x0: 800, y0: -2000, x1: 4200, y1: 300, x: 2575, y: -825 } },
|
||||
});
|
||||
assert.equal(t.rooms[0].name, 'Кладовка');
|
||||
// bbox wins when present (both are valid anchors); x/y covers bbox-less dialects
|
||||
assert.equal(t.rooms[0].cx, 2500);
|
||||
const t2 = readVacTelemetry({ vacuum_position: { x: 0, y: 0 }, rooms: { 2: { name: 'Кладовка', x: 2575, y: -825 } } });
|
||||
assert.equal(t2.rooms[0].cx, 2575);
|
||||
});
|
||||
|
||||
test('autoCalibrate matches by name and solves', () => {
|
||||
const f = ([x, y]) => [0.01 * x + 100, -0.01 * y + 900];
|
||||
const vac = [
|
||||
{ id: '1', name: 'Kitchen', cx: 1000, cy: 2000 },
|
||||
{ id: '2', name: 'Bed Room', cx: 9000, cy: 2500 },
|
||||
{ id: '3', name: 'office', cx: 3000, cy: 8000 },
|
||||
{ id: '4', name: 'Garage', cx: 5000, cy: 5000 }, // no plan match
|
||||
];
|
||||
const plan = [
|
||||
{ name: 'kitchen', cx: f([1000, 2000])[0], cy: f([1000, 2000])[1] },
|
||||
{ name: 'BEDROOM', cx: f([9000, 2500])[0], cy: f([9000, 2500])[1] },
|
||||
{ name: 'Office', cx: f([3000, 8000])[0], cy: f([3000, 8000])[1] },
|
||||
];
|
||||
const r = autoCalibrate(vac, plan);
|
||||
assert.ok(r);
|
||||
assert.deepEqual(r.matched, ['Kitchen', 'Bed Room', 'office']);
|
||||
assert.ok(r.residual < 1e-6);
|
||||
// two matches only -> null
|
||||
assert.equal(autoCalibrate(vac.slice(0, 2), plan.slice(0, 2)), null);
|
||||
});
|
||||
|
||||
test('thinPath keeps corners, drops straight-line noise', () => {
|
||||
const pts = [];
|
||||
for (let i = 0; i <= 100; i++) pts.push([i, 0]);
|
||||
for (let i = 1; i <= 100; i++) pts.push([100, i]);
|
||||
const out = thinPath(pts, 0.5);
|
||||
assert.ok(out.length <= 5, String(out.length));
|
||||
assert.deepEqual(out[0], [0, 0]);
|
||||
assert.deepEqual(out[out.length - 1], [100, 100]);
|
||||
assert.ok(out.some((p) => p[0] === 100 && p[1] === 0)); // the corner survives
|
||||
});
|
||||
|
||||
test('pushTrailPoint dedups and respects the cap', () => {
|
||||
let buf = [];
|
||||
for (let i = 0; i < 3000; i++) buf = pushTrailPoint(buf, [i, (i * 7) % 13], 0.5);
|
||||
assert.ok(buf.length <= 600, String(buf.length));
|
||||
buf = pushTrailPoint(buf, buf[buf.length - 1], 0.5); // dup ignored
|
||||
assert.ok(buf.length <= 600);
|
||||
});
|
||||
|
||||
test('isVacMoving', () => {
|
||||
assert.ok(isVacMoving('cleaning'));
|
||||
assert.ok(isVacMoving('returning'));
|
||||
assert.ok(!isVacMoving('docked'));
|
||||
assert.ok(!isVacMoving('idle'));
|
||||
assert.ok(!isVacMoving(undefined));
|
||||
});
|
||||
|
||||
test('fitMatrix/fitFromMatrix round-trip, mirror and quarters', async () => {
|
||||
const { fitMatrix, fitFromMatrix, initialFit, reanchorFit } = await import('../test-build/vacuum.js');
|
||||
for (const rot of [0, 90, 180, 270]) for (const mir of [false, true]) {
|
||||
const p = { ox: 123.4, oy: -55.5, s: 0.083, rot, mir };
|
||||
const q = fitFromMatrix(fitMatrix(p));
|
||||
assert.equal(q.rot, rot, `rot ${rot} mir ${mir}`);
|
||||
assert.equal(q.mir, mir);
|
||||
assert.ok(Math.abs(q.s - p.s) < 1e-9 && Math.abs(q.ox - p.ox) < 1e-9 && Math.abs(q.oy - p.oy) < 1e-9);
|
||||
}
|
||||
// the real X50 matrix shape: X forward, Y flipped == mir + 180? decompose sanity
|
||||
const q = fitFromMatrix([0.08, 0, 590, 0, -0.08, 677]);
|
||||
assert.equal(q.mir, true);
|
||||
// initialFit centres the map bbox on the canvas
|
||||
const rooms = [{ id: '1', name: 'A', cx: 500, cy: 500, x0: 0, y0: 0, x1: 1000, y1: 1000 }];
|
||||
const f = initialFit(rooms, [0, 0, 1000, 1000]);
|
||||
const m = fitMatrix(f);
|
||||
const c = applyAffine(m, 500, 500);
|
||||
assert.ok(Math.abs(c[0] - 500) < 1e-6 && Math.abs(c[1] - 500) < 1e-6);
|
||||
assert.ok(Math.abs(1000 * f.s - 600) < 1e-6); // 60% of the canvas
|
||||
assert.equal(f.mir, true);
|
||||
// reanchor keeps the chosen source point fixed through a rotation
|
||||
const p2 = { ...f, rot: 90 };
|
||||
const r2 = reanchorFit(p2, f, 500, 500);
|
||||
const c2 = applyAffine(fitMatrix(r2), 500, 500);
|
||||
assert.ok(Math.abs(c2[0] - c[0]) < 1e-6 && Math.abs(c2[1] - c[1]) < 1e-6);
|
||||
});
|
||||
|
||||
// HP-1540-02: the map-id contract, mirrored by trails.py resolve_map_id —
|
||||
// the first value that is not null/undefined wins; zero and '' are valid ids.
|
||||
test('vacMapIdFromAttrs: first NOT-nullish value wins, zero survives', () => {
|
||||
assert.equal(vacMapIdFromAttrs({ map_index: 0 }), '0');
|
||||
assert.equal(vacMapIdFromAttrs({ map_index: '0' }), '0');
|
||||
assert.equal(vacMapIdFromAttrs({ map_name: '', selected_map: 'Floor' }), '');
|
||||
assert.equal(vacMapIdFromAttrs({ map_name: 'A', map_index: 0 }), 'A');
|
||||
assert.equal(vacMapIdFromAttrs({ current_map: 2 }), '2');
|
||||
assert.equal(vacMapIdFromAttrs({ selected_map: 'Vac' }), 'Vac');
|
||||
assert.equal(vacMapIdFromAttrs({}), 'default');
|
||||
});
|
||||
|
||||
// HP-1541-01: the vacuum-entity fallback half of the contract. Truthiness
|
||||
// here turned selected_map: 0 into 'default' while the server recorder
|
||||
// stored the trail under '0' — reloads never showed the saved run.
|
||||
test('vacMapIdWithFallback: selected_map 0 / "0" / "" survive, nullish falls back', () => {
|
||||
assert.equal(vacMapIdWithFallback('default', 0), '0');
|
||||
assert.equal(vacMapIdWithFallback('default', '0'), '0');
|
||||
assert.equal(vacMapIdWithFallback('default', ''), '');
|
||||
assert.equal(vacMapIdWithFallback('default', 'Vac'), 'Vac');
|
||||
assert.equal(vacMapIdWithFallback('default', null), 'default');
|
||||
assert.equal(vacMapIdWithFallback('default', undefined), 'default');
|
||||
assert.equal(vacMapIdWithFallback('1', 0), '1'); // telemetry wins over fallback
|
||||
});
|
||||
|
||||
// HP-1541-01 cross-runtime contract: for the same inputs the card-side chain
|
||||
// (vacMapIdFromAttrs -> vacMapIdWithFallback) must yield exactly what
|
||||
// trails.py resolve_map_id stores. Mirrored by
|
||||
// tests_backend/test_trail_recorder.py::test_map_id_contract_first_not_none_wins.
|
||||
test('map-id contract: frontend chain matches backend resolve_map_id', () => {
|
||||
const chain = (srcAttrs, vacSel) => vacMapIdWithFallback(vacMapIdFromAttrs(srcAttrs), vacSel);
|
||||
assert.equal(chain({}, 0), '0');
|
||||
assert.equal(chain({}, '0'), '0');
|
||||
assert.equal(chain({}, ''), '');
|
||||
assert.equal(chain({}, 'Vac'), 'Vac');
|
||||
assert.equal(chain({}, undefined), 'default');
|
||||
assert.equal(chain({ map_index: 0 }, 'Vac'), '0'); // source wins over vacuum
|
||||
});
|
||||
|
||||
test('readVacTelemetry keeps numeric zero map_index as map id (HP-1540-02)', () => {
|
||||
const t = readVacTelemetry({ vacuum_position: { x: 1, y: 2 }, map_index: 0 });
|
||||
assert.equal(t.mapId, '0');
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
"""TrailRecorder wiring: subscription callback, dialects, run end.
|
||||
|
||||
Loads trails.py with STUBBED Home Assistant modules — but only inside a
|
||||
snapshot of sys.modules that is restored immediately afterwards. Injecting
|
||||
fake `homeassistant` modules globally poisons the real HA harness running in
|
||||
the same pytest session (it did, once).
|
||||
"""
|
||||
import sys, types, pathlib, importlib.util
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _load_trails():
|
||||
saved = {k: v for k, v in sys.modules.items() if k == "homeassistant" or k.startswith(("homeassistant.", "houseplan"))}
|
||||
try:
|
||||
for name in list(sys.modules):
|
||||
if name == "homeassistant" or name.startswith(("homeassistant.", "houseplan")):
|
||||
del sys.modules[name]
|
||||
ha = types.ModuleType("homeassistant"); sys.modules["homeassistant"] = ha
|
||||
core = types.ModuleType("homeassistant.core")
|
||||
core.HomeAssistant = object
|
||||
core.callback = lambda f: f
|
||||
sys.modules["homeassistant.core"] = core
|
||||
sys.modules["homeassistant.helpers"] = types.ModuleType("homeassistant.helpers")
|
||||
er = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||
er.async_get = lambda hass: None
|
||||
er.async_entries_for_device = lambda reg, dev: []
|
||||
sys.modules["homeassistant.helpers.entity_registry"] = er
|
||||
ev = types.ModuleType("homeassistant.helpers.event")
|
||||
ev.async_call_later = lambda hass, delay, cb: (lambda: None)
|
||||
ev.async_track_state_change_event = lambda hass, ents, cb: (lambda: None)
|
||||
sys.modules["homeassistant.helpers.event"] = ev
|
||||
stm = types.ModuleType("homeassistant.helpers.storage")
|
||||
|
||||
class _Store:
|
||||
def __init__(self, *a, **k): pass
|
||||
async def async_load(self): return None
|
||||
async def async_save(self, d): pass
|
||||
|
||||
stm.Store = _Store
|
||||
sys.modules["homeassistant.helpers.storage"] = stm
|
||||
pkg = types.ModuleType("houseplan"); pkg.__path__ = [str(ROOT / "custom_components" / "houseplan")]
|
||||
sys.modules["houseplan"] = pkg
|
||||
c = types.ModuleType("houseplan.const"); c.DOMAIN = "houseplan"
|
||||
sys.modules["houseplan.const"] = c
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"houseplan.trails", str(ROOT / "custom_components" / "houseplan" / "trails.py")
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["houseplan.trails"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
finally:
|
||||
for name in list(sys.modules):
|
||||
if name == "homeassistant" or name.startswith(("homeassistant.", "houseplan")):
|
||||
del sys.modules[name]
|
||||
sys.modules.update(saved)
|
||||
|
||||
|
||||
trails = _load_trails()
|
||||
|
||||
|
||||
class S:
|
||||
def __init__(self, state, attrs): self.state, self.attributes = state, attrs
|
||||
|
||||
|
||||
class States:
|
||||
def __init__(self, d): self.d = d
|
||||
def get(self, k): return self.d.get(k)
|
||||
|
||||
|
||||
class Bus:
|
||||
def __init__(self): self.fired = []
|
||||
def async_fire(self, *a): self.fired.append(a)
|
||||
|
||||
|
||||
class Hass:
|
||||
def __init__(self, states): self.states, self.bus, self.data = States(states), Bus(), {}
|
||||
|
||||
|
||||
def _rec():
|
||||
states = {
|
||||
"vacuum.x50": S("cleaning", {"selected_map": "Первый этаж"}),
|
||||
"camera.map": S("idle", {"vacuum_position": {"x": 1000, "y": -500, "a": 90}, "map_index": 1}),
|
||||
}
|
||||
hass = Hass(states)
|
||||
rec = trails.TrailRecorder(hass, None)
|
||||
rec.pairs = {"camera.map": [("m1", "vacuum.x50")]}
|
||||
return rec, hass, states
|
||||
|
||||
|
||||
class E:
|
||||
def __init__(self, eid): self.data = {"entity_id": eid}
|
||||
|
||||
|
||||
def test_state_events_record_points_and_map_id():
|
||||
rec, hass, states = _rec()
|
||||
rec._on_state(E("camera.map"))
|
||||
states["camera.map"] = S("idle", {"vacuum_position": {"x": 1100, "y": -500}, "map_index": 1})
|
||||
rec._on_state(E("camera.map"))
|
||||
run = rec.book.data["m1"]["current"]
|
||||
assert run["points"] == [[1000.0, -500.0], [1100.0, -500.0]]
|
||||
assert run["map_id"] == "1"
|
||||
assert hass.bus.fired, "live cards must be notified"
|
||||
|
||||
|
||||
def test_docking_ends_the_run():
|
||||
rec, hass, states = _rec()
|
||||
rec._on_state(E("camera.map"))
|
||||
states["vacuum.x50"] = S("docked", {})
|
||||
rec._on_state(E("vacuum.x50"))
|
||||
assert rec.book.data["m1"]["current"]["ended"] is not None
|
||||
|
||||
|
||||
def test_sample_seeds_a_run_already_in_progress():
|
||||
# HA restarted mid-cleanup: the first point must not wait for an event
|
||||
rec, _hass, _states = _rec()
|
||||
assert rec._sample("camera.map", 123.0)
|
||||
assert rec.book.data["m1"]["current"]["points"] == [[1000.0, -500.0]]
|
||||
|
||||
|
||||
def test_junk_position_ignored():
|
||||
rec, _hass, states = _rec()
|
||||
states["camera.map"] = S("idle", {"vacuum_position": {"x": "nope", "y": 1}})
|
||||
assert not rec._sample("camera.map", 1.0)
|
||||
states["camera.map"] = S("idle", {})
|
||||
assert not rec._sample("camera.map", 1.0)
|
||||
|
||||
|
||||
def test_unknown_source_is_noop():
|
||||
rec, _hass, _states = _rec()
|
||||
assert not rec._sample("camera.other", 1.0)
|
||||
|
||||
|
||||
def test_object_style_position_is_read():
|
||||
# Tasshack in-memory attributes hold a Point OBJECT, not a dict
|
||||
class Point:
|
||||
def __init__(self, x, y): self.x, self.y = x, y
|
||||
rec, _hass, states = _rec()
|
||||
states["camera.map"] = S("idle", {"vacuum_position": Point(2020, 3096), "map_index": 1})
|
||||
assert rec._sample("camera.map", 5.0)
|
||||
assert rec.book.data["m1"]["current"]["points"] == [[2020.0, 3096.0]]
|
||||
|
||||
|
||||
def test_unavailable_vacuum_is_no_verdict():
|
||||
# HA boot: the vacuum reads unavailable — the open run must NOT be ended
|
||||
rec, _hass, states = _rec()
|
||||
rec._sample("camera.map", 1.0)
|
||||
states["vacuum.x50"] = S("unavailable", {})
|
||||
assert not rec._sample("camera.map", 2.0)
|
||||
assert rec.book.data["m1"]["current"]["ended"] is None
|
||||
del states["vacuum.x50"]
|
||||
assert not rec._sample("camera.map", 3.0)
|
||||
assert rec.book.data["m1"]["current"]["ended"] is None
|
||||
|
||||
|
||||
# ---------------- v1.54.0 audit regressions ----------------
|
||||
|
||||
def _run_isolated(coro):
|
||||
"""Run a coroutine on a private loop WITHOUT touching the ambient one.
|
||||
|
||||
asyncio.run() clears the thread's current-loop slot when it finishes; in
|
||||
the CI HA harness (pytest-asyncio keeps a session event loop) that
|
||||
poisoned the setup of every test that followed — 'There is no current
|
||||
event loop in thread MainThread' across whole files.
|
||||
"""
|
||||
import asyncio
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
|
||||
|
||||
def test_map_index_zero_matches_frontend_contract():
|
||||
# HP-1540-02: `map_index: 0` is a VALID first map. The old or-chain
|
||||
# dropped it and fell through to selected_map — the server stored the
|
||||
# run under a key the renderer never looked up.
|
||||
rec, _hass, states = _rec()
|
||||
states["camera.map"] = S("idle", {"vacuum_position": {"x": 10, "y": 20}, "map_index": 0})
|
||||
assert rec._sample("camera.map", 1.0)
|
||||
assert rec.book.data["m1"]["current"]["map_id"] == "0"
|
||||
|
||||
|
||||
def test_vacuum_selected_map_zero_fallback_recorded_as_zero():
|
||||
# HP-1541-01: source names no map, vacuum reports selected_map: 0 — the
|
||||
# recorder must store the run under "0", the same id the fixed card-side
|
||||
# fallback (vacMapIdWithFallback) resolves. Before the fix the card asked
|
||||
# for calibration/trails under "default" and never found this run.
|
||||
rec, _hass, states = _rec()
|
||||
states["camera.map"] = S("idle", {"vacuum_position": {"x": 10, "y": 20}})
|
||||
states["vacuum.x50"] = S("cleaning", {"selected_map": 0})
|
||||
assert rec._sample("camera.map", 1.0)
|
||||
assert rec.book.data["m1"]["current"]["map_id"] == "0"
|
||||
|
||||
|
||||
def test_map_id_contract_first_not_none_wins():
|
||||
# HP-1540-02: the shared contract — first NOT-None value, stringified
|
||||
cases = [
|
||||
({"map_index": 0}, {"selected_map": "Floor"}, "0"),
|
||||
({"map_index": "0"}, {}, "0"),
|
||||
({"map_name": ""}, {"selected_map": "Floor"}, ""),
|
||||
({"map_name": "A", "map_index": 0}, {}, "A"),
|
||||
({"current_map": 2}, {}, "2"),
|
||||
({"selected_map": "Src"}, {"selected_map": "Vac"}, "Src"),
|
||||
({}, {"selected_map": "Vac"}, "Vac"),
|
||||
# HP-1541-01: the vacuum-entity fallback with a zero-ish id — must
|
||||
# match the card's vacMapIdWithFallback (test/vacuum.test.mjs)
|
||||
({}, {"selected_map": 0}, "0"),
|
||||
({}, {"selected_map": "0"}, "0"),
|
||||
({}, {"selected_map": ""}, ""),
|
||||
({}, {}, "default"),
|
||||
]
|
||||
for src_attrs, vac_attrs, want in cases:
|
||||
assert trails.resolve_map_id(src_attrs, vac_attrs) == want, (src_attrs, vac_attrs)
|
||||
|
||||
|
||||
def test_one_source_two_floor_markers_both_record():
|
||||
# HP-1540-03: the multi-floor case — the same robot placed on two floors.
|
||||
# Both markers must receive the server-side run, on every map.
|
||||
states = {
|
||||
"vacuum.x50": S("cleaning", {}),
|
||||
"camera.map": S("idle", {"vacuum_position": {"x": 100, "y": 200}, "map_index": 0}),
|
||||
}
|
||||
hass = Hass(states)
|
||||
rec = trails.TrailRecorder(hass, None)
|
||||
rec.pairs = {"camera.map": [("m_floor1", "vacuum.x50"), ("m_floor2", "vacuum.x50")]}
|
||||
rec._on_state(E("camera.map"))
|
||||
# the robot moves to the second map: BOTH books rotate to the new run
|
||||
states["camera.map"] = S("idle", {"vacuum_position": {"x": 300, "y": 400}, "map_index": 1})
|
||||
rec._on_state(E("camera.map"))
|
||||
for marker in ("m_floor1", "m_floor2"):
|
||||
book = rec.book.data[marker]
|
||||
assert book["previous"]["map_id"] == "0", marker
|
||||
assert book["previous"]["points"] == [[100.0, 200.0]], marker
|
||||
assert book["current"]["map_id"] == "1", marker
|
||||
assert book["current"]["points"] == [[300.0, 400.0]], marker
|
||||
|
||||
|
||||
def test_refresh_builds_pair_lists_and_dedups_subscription():
|
||||
# HP-1540-03: two markers over one source/vacuum → one entity set, both pairs
|
||||
import asyncio
|
||||
|
||||
tracked = []
|
||||
|
||||
def track(hass, ents, cb):
|
||||
tracked.append(list(ents))
|
||||
return lambda: None
|
||||
|
||||
old_track = trails.async_track_state_change_event
|
||||
trails.async_track_state_change_event = track
|
||||
try:
|
||||
cfgm = [
|
||||
{"id": "m_f1", "binding": "entity:vacuum.x50", "vacuum": {"source": "camera.map"}},
|
||||
{"id": "m_f2", "binding": "entity:vacuum.x50", "vacuum": {"source": "camera.map"}},
|
||||
]
|
||||
|
||||
class CS:
|
||||
async def async_load(self):
|
||||
return {"config": {"markers": cfgm}}
|
||||
|
||||
class RT:
|
||||
config_store = CS()
|
||||
|
||||
hass = Hass({
|
||||
"vacuum.x50": S("docked", {}),
|
||||
"camera.map": S("idle", {}),
|
||||
})
|
||||
rec = trails.TrailRecorder(hass, RT())
|
||||
_run_isolated(rec.async_refresh())
|
||||
assert rec.pairs == {"camera.map": [("m_f1", "vacuum.x50"), ("m_f2", "vacuum.x50")]}
|
||||
assert tracked == [["camera.map", "vacuum.x50"]]
|
||||
finally:
|
||||
trails.async_track_state_change_event = old_track
|
||||
|
||||
|
||||
def test_overlapping_refreshes_leave_one_subscription_teardown_zero():
|
||||
# HP-1540-05: two config/set refreshes racing across the awaited load used
|
||||
# to BOTH subscribe; teardown removed only the last handle and the other
|
||||
# callback leaked until HA restart.
|
||||
import asyncio
|
||||
|
||||
active = []
|
||||
seq = {"n": 0}
|
||||
|
||||
def track(hass, ents, cb):
|
||||
seq["n"] += 1
|
||||
hid = seq["n"]
|
||||
active.append(hid)
|
||||
return lambda: active.remove(hid)
|
||||
|
||||
old_track = trails.async_track_state_change_event
|
||||
trails.async_track_state_change_event = track
|
||||
try:
|
||||
gate = asyncio.Event()
|
||||
|
||||
class CS:
|
||||
async def async_load(self):
|
||||
await gate.wait()
|
||||
return {"config": {"markers": [
|
||||
{"id": "m1", "binding": "entity:vacuum.x50", "vacuum": {"source": "camera.map"}},
|
||||
]}}
|
||||
|
||||
class RT:
|
||||
config_store = CS()
|
||||
|
||||
hass = Hass({
|
||||
"vacuum.x50": S("docked", {}),
|
||||
"camera.map": S("idle", {}),
|
||||
})
|
||||
|
||||
async def scenario():
|
||||
rec = trails.TrailRecorder(hass, RT())
|
||||
t1 = asyncio.ensure_future(rec.async_refresh())
|
||||
t2 = asyncio.ensure_future(rec.async_refresh())
|
||||
for _ in range(3): # both tasks are launched; one parks on the gate
|
||||
await asyncio.sleep(0)
|
||||
gate.set()
|
||||
await t1
|
||||
await t2
|
||||
assert len(active) == 1, f"exactly one live subscription, got {active}"
|
||||
# teardown during an in-flight refresh must also end with zero
|
||||
gate.clear()
|
||||
t3 = asyncio.ensure_future(rec.async_refresh())
|
||||
for _ in range(3):
|
||||
await asyncio.sleep(0)
|
||||
rec.teardown()
|
||||
gate.set()
|
||||
await t3
|
||||
assert active == [], f"teardown must leave zero subscriptions, got {active}"
|
||||
|
||||
_run_isolated(scenario())
|
||||
finally:
|
||||
trails.async_track_state_change_event = old_track
|
||||
@@ -0,0 +1,66 @@
|
||||
"""TrailBook: the pure part of the server-side vacuum trails."""
|
||||
import sys, pathlib
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "custom_components" / "houseplan"))
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"trailbook_pure",
|
||||
pathlib.Path(__file__).parent.parent / "custom_components" / "houseplan" / "trails.py",
|
||||
)
|
||||
# import only TrailBook without HA deps: read the source and exec the class
|
||||
src = (pathlib.Path(__file__).parent.parent / "custom_components" / "houseplan" / "trails.py").read_text(encoding="utf-8")
|
||||
ns = {"Any": object, "annotations": None}
|
||||
exec(src[src.index("TRAIL_CAP"):src.index("class TrailRecorder")], ns)
|
||||
TrailBook = ns["TrailBook"]
|
||||
TRAIL_CAP = ns["TRAIL_CAP"]
|
||||
|
||||
|
||||
def test_append_dedups_and_records():
|
||||
b = TrailBook()
|
||||
assert b.on_point("m", "0", 1.0, 2.0, 100.0)
|
||||
assert not b.on_point("m", "0", 1.0, 2.0, 101.0) # same point
|
||||
assert b.on_point("m", "0", 3.0, 4.0, 102.0)
|
||||
run = b.data["m"]["current"]
|
||||
assert run["points"] == [[1.0, 2.0], [3.0, 4.0]]
|
||||
assert run["ended"] is None
|
||||
|
||||
|
||||
def test_end_then_new_run_rotates_current_to_previous():
|
||||
b = TrailBook()
|
||||
b.on_point("m", "0", 1, 1, 1.0)
|
||||
b.on_point("m", "0", 2, 2, 2.0)
|
||||
assert b.end_run("m", 3.0)
|
||||
assert not b.end_run("m", 4.0) # idempotent
|
||||
b.on_point("m", "0", 9, 9, 5.0)
|
||||
rec = b.data["m"]
|
||||
assert rec["previous"]["points"] == [[1, 1], [2, 2]]
|
||||
assert rec["previous"]["ended"] == 3.0
|
||||
assert rec["current"]["points"] == [[9, 9]]
|
||||
# a third run forgets the first entirely — exactly two are kept
|
||||
b.end_run("m", 6.0)
|
||||
b.on_point("m", "0", 7, 7, 7.0)
|
||||
assert rec if rec is b.data["m"] else True
|
||||
assert b.data["m"]["previous"]["points"] == [[9, 9]]
|
||||
|
||||
|
||||
def test_map_switch_mid_run_starts_a_new_run():
|
||||
b = TrailBook()
|
||||
b.on_point("m", "floor1", 1, 1, 1.0)
|
||||
b.on_point("m", "floor2", 2, 2, 2.0)
|
||||
assert b.data["m"]["current"]["map_id"] == "floor2"
|
||||
assert b.data["m"]["previous"]["map_id"] == "floor1"
|
||||
|
||||
|
||||
def test_cap_decimates_but_keeps_the_freshest_point():
|
||||
b = TrailBook()
|
||||
for i in range(TRAIL_CAP + 1):
|
||||
b.on_point("m", "0", float(i), 0.0, float(i))
|
||||
pts = b.data["m"]["current"]["points"]
|
||||
assert len(pts) <= TRAIL_CAP // 2 + 2
|
||||
assert pts[-1] == [float(TRAIL_CAP), 0.0]
|
||||
|
||||
|
||||
def test_junk_store_data_tolerated():
|
||||
b = TrailBook("not a dict")
|
||||
assert b.data == {}
|
||||
b.on_point("m", "0", 1, 1, 1.0)
|
||||
assert b.data["m"]["current"]["points"] == [[1, 1]]
|
||||
@@ -164,6 +164,24 @@ def test_space_display_settings():
|
||||
v.SPACE_SCHEMA(bad_mode)
|
||||
|
||||
|
||||
def test_bg_color_setting():
|
||||
"""Background-around-the-plan color: strict #rrggbb, globally and per space."""
|
||||
ok_space = {
|
||||
"id": "f1", "title": "F", "view_box": [0, 0, 1, 1], "rooms": [],
|
||||
"settings": {"bg_color": "#102030"},
|
||||
}
|
||||
v.SPACE_SCHEMA(ok_space)
|
||||
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_color": "#A1b2C3"}})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA(dict(ok_space, settings={"bg_color": "red"}))
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_color": "url(javascript:x)"}})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_color": "#12345"}})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_color": "#1234567"}})
|
||||
|
||||
|
||||
def test_space_temp_bounds():
|
||||
"""Temperature comfort bounds validate as floats; temp fill mode accepted."""
|
||||
ok = {
|
||||
@@ -390,6 +408,16 @@ def test_every_display_mode_the_editor_offers_is_accepted():
|
||||
v.MARKER_SCHEMA(_marker(display="wat"))
|
||||
|
||||
|
||||
def test_run_target_is_bounded_to_runnable_domains():
|
||||
"""Owner's spec 2026-07-29: a tap may RUN automations, scripts and scenes —
|
||||
and nothing else. The target rides the marker, so the schema is the door."""
|
||||
for ok in ("automation.morning", "script.curtains", "scene.movie"):
|
||||
v.MARKER_SCHEMA(_marker(tap_action="run", tap_target=ok, tap_confirm=True))
|
||||
for bad in ("light.lamp", "lock.front_door", "shell_command.rm", "automation.", "x"):
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA(_marker(tap_action="run", tap_target=bad))
|
||||
|
||||
|
||||
def test_every_tap_action_the_editor_offers_is_accepted():
|
||||
for action in _ts_list("TAP_ACTIONS"):
|
||||
v.MARKER_SCHEMA(_marker(tap_action=action))
|
||||
@@ -913,3 +941,43 @@ def test_check_quota_refuses_when_the_disk_is_nearly_full(tmp_path, monkeypatch)
|
||||
with pytest.raises(plans.QuotaError) as e:
|
||||
plans.check_quota(d, 1, max_bytes=10 ** 12, max_files=10 ** 6)
|
||||
assert e.value.reason == "low_disk_space"
|
||||
|
||||
|
||||
class TestVacuum:
|
||||
"""marker.vacuum (docs/VACUUM.md): optional everywhere, matrices strict."""
|
||||
|
||||
def test_full_object_passes(self):
|
||||
v.MARKER_SCHEMA(_marker(vacuum={
|
||||
"live": True, "trail": True, "room_highlight": False,
|
||||
"source": "camera.robo_map",
|
||||
"calibration": {"0": [0.02, 0.0, 300.0, 0.0, 0.02, 400.0]},
|
||||
"segment_map": {"16": "kitchen"},
|
||||
}))
|
||||
|
||||
def test_absent_and_none_pass(self):
|
||||
v.MARKER_SCHEMA(_marker())
|
||||
v.MARKER_SCHEMA(_marker(vacuum=None))
|
||||
|
||||
def test_bad_matrices_rejected(self):
|
||||
import pytest
|
||||
for bad in (
|
||||
[1, 2, 3, 4, 5], # 5 numbers
|
||||
[1, 2, 3, 4, 5, 6, 7], # 7 numbers
|
||||
[1, 2, 3, 4, 5, float("nan")], # non-finite
|
||||
[1, 2, 3, 4, 5, float("inf")],
|
||||
[1, 2, 3, 4, 5, "x"], # junk type
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
v.MARKER_SCHEMA(_marker(vacuum={"calibration": {"0": bad}}))
|
||||
|
||||
def test_unknown_keys_rejected(self):
|
||||
import pytest
|
||||
with pytest.raises(Exception):
|
||||
v.MARKER_SCHEMA(_marker(vacuum={"teleport": True}))
|
||||
|
||||
def test_trail_mode_bounded(self):
|
||||
import pytest
|
||||
for ok in ("never", "cleaning", "always", None):
|
||||
v.MARKER_SCHEMA(_marker(vacuum={"trail_mode": ok}))
|
||||
with pytest.raises(Exception):
|
||||
v.MARKER_SCHEMA(_marker(vacuum={"trail_mode": "sometimes"}))
|
||||
|
||||
+2
-1
@@ -8,7 +8,8 @@
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"src/logic.ts",
|
||||
"src/logic.ts", "src/vacuum.ts",
|
||||
"src/resize.ts",
|
||||
"src/rules.ts",
|
||||
"src/devices.ts",
|
||||
"src/types.ts",
|
||||
|
||||
Reference in New Issue
Block a user