Compare commits

...
3 Commits
Author SHA1 Message Date
Matysh 6db9eb0a66 test: use access token for non-admin websocket
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 4m33s
Validate / backend (push) Failing after 10m31s
Validate / smoke (push) Failing after 20m7s
2026-08-07 13:10:27 +03:00
Matysh 3028122016 v1.60.0: harden background editing and device state 2026-08-07 13:02:46 +03:00
Matysh 29fb9deb43 v1.60.0-beta.1: unify background editing and device deletion 2026-08-07 11:14:20 +03:00
68 changed files with 5586 additions and 1532 deletions
+6 -4
View File
@@ -32,8 +32,10 @@ jobs:
run: npm test
- name: Build
run: npm run build
- name: Card bundle in sync with integration
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
- name: Card bundle snapshots in sync
run: |
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
smoke:
# audit T2: the end-to-end layer used to run only when a human remembered.
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
@@ -47,8 +49,8 @@ jobs:
- name: Install Chromium for Playwright
run: npx playwright install --with-deps chromium
- name: Build a FRESH bundle for the smokes
# the committed demo/srv/assets copy is a snapshot; testing it would
# report green about code that no longer exists (audit T2)
# Never depend on a previous job's artefact; build the exact source
# under test again before the browser suite.
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Smoke suite
run: |
+3 -3
View File
@@ -12,9 +12,9 @@ Standard commands live in `package.json` scripts, `CONTRIBUTING.md`, and `docs/D
The startup update script already runs `npm ci`, provisions a Python 3.13 backend venv at `.venv-backend`, and installs Playwright Chromium. You do not need to reinstall dependencies.
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, ~270 tests), `npm run build`. After building, keep the integration copy in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/`. CI enforces `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` byte-for-byte.
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (126 tests). Running `python3 -m pytest tests_backend` on the system 3.12 silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the ~83 pure tests.
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed `demo/srv/assets/houseplan-card.js` is a stale snapshot; testing it reports green about code that no longer exists. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, 424 tests at v1.60.0), `npm run build`. After building, keep both committed snapshots in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/` and `cp dist/houseplan-card.js demo/srv/assets/`. CI enforces both comparisons byte-for-byte.
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (150 tests at v1.60.0: 100 pure + 50 HA harness). Running `python3 -m pytest tests_backend` without Home Assistant silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the pure set.
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed demo snapshot must remain byte-identical to `dist` (CI checks it); rebuilding first also guarantees the browser suite tests the current source in an uncommitted worktree. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
- **Demo harness render quirk**: the fake `hass` in `demo.html` is set once, so opening the page directly in a browser renders the floor plan but **device icons only appear after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does not. This is a harness limitation, not a card bug.
- **Known environment-sensitive smoke**: `demo/smoke_opening_measure.mjs` fails two sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It reproduces against the pristine committed bundle, so treat it as pre-existing/pixel-precision, not a regression you introduced.
- **Owner's local Windows checkout is invisible here.** Path
+10 -5
View File
@@ -101,9 +101,10 @@ Assistant integration:
| **Zoom** | Smooth vector zoom | Usually a fixed image | SVG / virtual canvas |
**One sentence:** House Plan is the shared, area-aware live map of your home —
not a drawing app for furniture. If you want to sketch sofas and walls from
scratch, a draw-centric card may fit better; if you already have a plan image
and a real HA areas registry, House Plan keeps every tablet on the same layout.
not a general-purpose CAD package. Its Background editor covers practical
decor, labels and furniture; if you need unrestricted architectural drafting,
a draw-centric tool may fit better. With a plan and HA areas, House Plan keeps
every tablet on the same live layout.
Key advantages in short:
@@ -112,11 +113,15 @@ Key advantages in short:
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
a light is shining, a socket is powering, a fan is spinning, media is playing, a vacuum is
a light is shining, a socket is powering, a fan is spinning, a vacuum is
cleaning, a radiator valve is actually heating (not merely enabled). Orange = open / unlocked.
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
A translucent icon = unavailable. Dark = idle.
- **A coherent visual Background editor.** Draw lines/shapes, place labels and
furniture, edit physical styles and transform every object with the same
selection model. The plan image has its own move/resize/rotate tool, numeric
properties and shared Undo/Redo.
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
---
@@ -198,7 +203,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions and can be moved and resized at any time in the Background editor.
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions by default and can be moved, resized or rotated at any time in the Background editor.
![Space creation dialog](docs/images/03-space-dialog.png)
+7 -6
View File
@@ -99,9 +99,10 @@ YAML/SVG, либо конфиг живёт в YAML карточки. House Plan
| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст |
**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
редактор мебели. Если нужно с нуля нарисовать диваны и стены — лучше
draw-карточка; если есть картинка плана и зоны в HA — House Plan держит один
и тот же layout на всех планшетах.
универсальная CAD-система. Редактор подложки покрывает практический декор,
надписи и мебель; для свободного архитектурного черчения лучше отдельный
draw-инструмент. При наличии плана и зон HA House Plan держит один живой layout
на всех планшетах.
Ключевые преимущества коротко:
@@ -110,12 +111,12 @@ draw-карточка; если есть картинка плана и зоны
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Подложку можно двигать и масштабировать.** Картинка плана не прибита к холсту: в редакторе подложки её тянут за тело и за угол, а плашка показывает реальный размер в метрах — чертёж и фотография плана наконец совмещаются.
- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
@@ -199,7 +200,7 @@ title: План дома
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка сохраняет свои пропорции, а подвинуть и отмасштабировать её можно в любой момент в редакторе подложки.
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка по умолчанию сохраняет пропорции, а подвинуть, изменить размер или повернуть её можно в любой момент в редакторе подложки.
![Диалог создания пространства](docs/images/03-space-dialog.png)
+1 -1
View File
@@ -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.59.2"
VERSION = "1.60.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.59.2"
"version": "1.60.0"
}
+42 -10
View File
@@ -90,6 +90,10 @@ class TrailBook:
return True
return False
def delete(self, marker: str) -> bool:
"""Forget every stored run of one plan marker."""
return self.data.pop(marker, None) is not None
class TrailRecorder:
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
@@ -131,6 +135,8 @@ class TrailRecorder:
cfg = stored.get("config") or {}
pairs: dict[str, list[tuple[str, str]]] = {}
for m in cfg.get("markers") or []:
if m.get("removed") is True:
continue
v = m.get("vacuum") or {}
src = v.get("source")
if not src or v.get("live") is False:
@@ -141,16 +147,7 @@ class TrailRecorder:
# 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
)
self._resubscribe()
# 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
@@ -158,6 +155,41 @@ class TrailRecorder:
for src in self.pairs:
self._sample(src, time.time())
async def async_delete(self, marker: str) -> bool:
"""Stop and erase one marker without racing subscription refresh/save."""
async with self._refresh_lock:
# The trail book owns deletion. When it has no such marker, this
# is a no-op and must not silently damage the live tracking graph.
removed = self.book.delete(marker)
if not removed:
return False
for src in list(self.pairs):
kept = [pair for pair in self.pairs[src] if pair[0] != marker]
if kept:
self.pairs[src] = kept
else:
del self.pairs[src]
self._resubscribe()
if self._unsub_save:
self._unsub_save()
self._unsub_save = None
await self.store.async_save(self.book.data)
self.hass.bus.async_fire("houseplan_trail_updated", {})
return True
def _resubscribe(self) -> None:
"""Replace the state subscription for the current pair graph."""
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 and not self._closed:
self._unsub_track = async_track_state_change_event(
self.hass, sorted(ents), self._on_state
)
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
+40 -7
View File
@@ -95,6 +95,8 @@ MAX_URL = 2000
# they can act on. For scale, a real three-floor home with ~200 devices stores
# about 70 KB, so this is ~30x headroom.
MAX_CONFIG_BYTES = 2 * 1024 * 1024
CELL_CM_MIN = 0.1
CELL_CM_MAX = 1000.0
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT)
@@ -283,6 +285,7 @@ MAX_DECOR_UNIT = 16
# could mean on a 1000-unit canvas, the rest is garbage insurance.
DECOR_TEXT_SCALE_MIN = 0.15
DECOR_TEXT_SCALE_MAX = 20.0
DECOR_TEXT_CM_MAX = 2000.0
# A furniture symbol id (docs/FURNITURE.md). Deliberately NOT the card's list:
# the backend must accept a plan written by a NEWER card, and a card that has
# learnt a new symbol must not have to wait for the integration to be updated
@@ -299,6 +302,10 @@ _FURN_SIZE = vol.All(_finite, vol.Range(min=0.0000001, max=CANVAS_LIMIT))
_DECOR_COMMON = {
vol.Required("id"): str,
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
# Physical centimetres are canonical. `width` remains accepted so plans
# written by older cards keep their exact appearance until edited.
vol.Optional("width_cm"): vol.All(_finite, vol.Range(min=0.1, max=100)),
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
}
# Decor lives on the same unbounded canvas as everything else (docs/CANVAS.md):
@@ -314,7 +321,10 @@ DECOR_SCHEMA = vol.Any(
# sizes are extents — negative/zero is garbage, not "canvas slack"
vol.Required("w"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
vol.Required("h"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
vol.Optional("fill"): bool},
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
vol.Optional("fill"): bool,
vol.Optional("fill_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("fill_opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0))},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
@@ -326,8 +336,11 @@ DECOR_SCHEMA = vol.Any(
# written before that keeps it, and it is read as the scale it
# used to render at. Kept in the schema so it stays BOUNDED.
vol.Optional("size"): vol.In(["s", "m", "l"]),
# size and rotation of the block (docs/LIVE-TEXT.md §block).
# Both optional: a shape without them is the old one exactly.
# Canonical physical font size plus the legacy scale. Both are
# accepted so older plans remain pixel-identical until edited
# or explicitly optimized.
vol.Optional("size_cm"): vol.All(
_finite, vol.Range(min=0.1, max=DECOR_TEXT_CM_MAX)),
vol.Optional("scale"): vol.All(
_finite, vol.Range(min=DECOR_TEXT_SCALE_MIN, max=DECOR_TEXT_SCALE_MAX)),
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
@@ -378,6 +391,12 @@ SPACE_SCHEMA = vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Match(SPACE_ID_RE.pattern)),
vol.Required("title"): str,
# Physical grid scale. It feeds every px/cell -> centimetres migration,
# so NaN/Infinity or an absurd value must not be allowed to manufacture
# invalid decor sizes later.
vol.Optional("cell_cm"): vol.All(
_finite, vol.Range(min=CELL_CM_MIN, max=CELL_CM_MAX)
),
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
vol.Optional("plan_url"): vol.Any(str, None),
# The canvas is square since v1.48.0. What used to be the space's own
@@ -389,10 +408,10 @@ SPACE_SCHEMA = vol.Schema(
vol.Optional("plan_aspect"): vol.Any(
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
),
# Backdrop placement (docs/BACKDROP.md): the picture may be moved and
# scaled UNIFORMLY on the canvas. All three are optional and their
# absence is the pre-v1.58.0 behaviour exactly — there is no migration,
# and an old config validates unchanged. The offset is a normalised
# Backdrop placement (docs/BACKDROP.md): the picture may be moved,
# resized per axis and rotated. Every transform field is optional; its
# complete absence is the pre-v1.58.0 behaviour exactly, and an old
# config validates unchanged. The offset is a normalised
# coordinate like every other one (the ±CANVAS_LIMIT garbage guard);
# the scale is a positive multiplier in a range a human could mean.
vol.Optional("plan_x"): vol.Any(None, _COORD),
@@ -400,6 +419,17 @@ SPACE_SCHEMA = vol.Schema(
vol.Optional("plan_scale"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
# New writes may stretch each axis independently and rotate. The old
# uniform field remains a read-compatible fallback for both axes.
vol.Optional("plan_scale_x"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
vol.Optional("plan_scale_y"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
vol.Optional("plan_angle"): vol.Any(
None, vol.All(_finite, vol.Range(min=-360.0, max=360.0))
),
vol.Required("view_box"): _view_box,
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
@@ -456,6 +486,9 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("space"): vol.Any(str, None),
vol.Optional("area"): vol.Any(str, None),
vol.Optional("hidden"): bool,
# A binding-level tombstone: not rendered or aggregated, but retained
# so automatic discovery does not put a deleted device straight back.
vol.Optional("removed"): bool,
vol.Optional("name"): _TEXT_OR_NONE,
vol.Optional("icon"): _TEXT_OR_NONE,
vol.Optional("model"): _TEXT_OR_NONE,
+86 -1
View File
@@ -73,6 +73,7 @@ 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_trail_delete)
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)
@@ -110,6 +111,36 @@ def _check_write(hass: HomeAssistant, connection) -> bool:
# ---------------- layout ----------------
def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
"""Drop positions which a deleted marker can no longer own.
HA device ids may be layout-only because auto-discovered devices need no
marker entry. Virtual ids are different: every live virtual marker is
explicit, so a missing `v_*` owner is stale data. The prefix itself is
only a legacy naming convention, however; an explicit real marker remains
authoritative even when its id happens to begin with `v_`.
"""
markers = config.get("markers") or []
removed_ids = {
str(m.get("id")) for m in markers if m.get("removed") is True
}
virtual_ids = {
str(m.get("id")) for m in markers
if m.get("removed") is not True and m.get("binding") == "virtual"
}
explicit_live_ids = {
str(m.get("id")) for m in markers
if m.get("removed") is not True and m.get("binding") != "virtual"
}
return {
marker_id: pos for marker_id, pos in layout.items()
if marker_id not in removed_ids
and (not marker_id.startswith("v_")
or marker_id in virtual_ids
or marker_id in explicit_live_ids)
}
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
@websocket_api.async_response
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
@@ -151,6 +182,7 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
data = await rt.store.async_load() or {}
current_rev = int(data.get("rev", 0))
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
@@ -158,10 +190,11 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
)
return
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
new_rev = current_rev + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"layout": msg["layout"], "rev": new_rev})
"layout": layout, "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -183,6 +216,39 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
if rt is None:
return
async with rt.write_lock:
# A stale browser may still finish a drag after another client deleted
# the marker. Its tombstone is the server-side authority: acknowledge
# but ignore the late point so re-adding starts without a zombie
# position.
config_data = await rt.config_store.async_load() or {}
config = config_data.get("config") or {}
markers = config.get("markers") or []
deleted = any(
str(m.get("id")) == msg["device_id"] and m.get("removed") is True
for m in markers
)
live_virtual = any(
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
and m.get("binding") == "virtual"
for m in markers
)
live_explicit = any(
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
for m in markers
)
orphan_virtual = (
msg["device_id"].startswith("v_")
and not live_virtual
and not live_explicit
)
if deleted or orphan_virtual:
data = await rt.store.async_load() or {}
connection.send_result(msg["id"], {
"ok": True,
"ignored": "removed" if deleted else "missing_virtual",
"rev": int(data.get("rev", 0)),
})
return
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
layout[msg["device_id"]] = msg["pos"]
@@ -1022,3 +1088,22 @@ async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConn
"""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 {}})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/trail/delete",
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=256)),
}
)
@websocket_api.async_response
async def ws_trail_delete(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
"""Permanently forget one deleted marker's current and previous runs."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete trails")
return
if _runtime(hass, connection, msg["id"]) is None:
return
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
removed = await rec.async_delete(msg["marker_id"]) if rec else False
connection.send_result(msg["id"], {"ok": True, "removed": removed})
+24 -20
View File
@@ -34,7 +34,11 @@ const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFram
const q = (sel) => page.evaluate((s) => window.__card.renderRoot.querySelectorAll(s).length, sel);
const spaceCfg = () => page.evaluate(() => {
const sp = window.__card._serverCfg.spaces.find((s) => s.id === 'f1');
return { x: sp.plan_x ?? null, y: sp.plan_y ?? null, k: sp.plan_scale ?? null };
return {
x: sp.plan_x ?? null, y: sp.plan_y ?? null,
sx: sp.plan_scale_x ?? null, sy: sp.plan_scale_y ?? null,
legacy: sp.plan_scale ?? null, angle: sp.plan_angle ?? null,
};
});
/** The image rect AS RENDERED (attributes of the <image> in the stage svg). */
const imageRect = () => page.evaluate(() => {
@@ -84,28 +88,24 @@ const tool = (t) => page.evaluate((t) => {
await mode('decor');
await settle();
// the frame is on screen the moment the backdrop editor opens…
check('frame_in_backdrop_editor', await q('.bdframe'), 1);
check('four_finger_handles', await q('.bdframe .bdhandle'), 4);
check('frame_hugs_the_picture', await frameRect(), BASE);
// Every Background session starts predictably on Select. The image frame is
// exclusive to its own tool and therefore cannot steal a Select/pan gesture.
check('no_frame_until_picture_tool', await q('.bdframe'), 0);
check('no_handles_until_picture_tool', await q('.bdframe .bdhandle'), 0);
check('no_picture_frame_until_picture_tool', await frameRect(), null);
check('untouched_picture_is_where_it_always_was', await imageRect(), BASE);
// …and the picture has a tool of its own, offered because f1 HAS a picture
check('the_picture_has_a_tool', await q('.decorbar .btn.dtool'), 8);
// …which is ARMED the moment the editor opens (owner 2026-08-05: «не
// получается двигать картинку-подложку в режиме редактора подложки» — the
// frame promised a draggable picture while the promise needed a tool nobody
// had found). The editor is named after the picture; it opens on it.
check('picture_tool_armed_on_open', await page.evaluate(() => window.__card._decorTool), 'backdrop');
check('grab_cursor', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), true);
// select still exists and still leaves the body to the pan (smoke_pan_any_zoom)
await tool('select');
await settle();
check('select_tool_armed_on_open', await page.evaluate(() => window.__card._decorTool), 'select');
check('no_grab_cursor_under_select', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), false);
await tool('backdrop');
await settle();
check('frame_still_there_under_its_own_tool', await q('.bdframe'), 1);
check('four_corners_plus_rotate_under_picture_tool', await q('.bdframe .bdhandle'), 5);
check('frame_hugs_the_picture_under_picture_tool', await frameRect(), BASE);
check('grab_cursor_under_picture_tool', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), true);
// a drawing tool owns the drag instead — no frame competing with it
await tool('rect');
await settle();
@@ -145,7 +145,8 @@ check('badge_gone_after_release', (await badges()).length, 0);
// corner lands on a lattice node within one step of where the finger let go
check('plan_x_saved', near(cfg.x, im.x / 1000, 1e-6), true);
check('plan_y_saved', near(cfg.y, (im.y - BASE.y) / 1000, 1e-6), true);
check('scale_untouched_by_a_move', cfg.k, 1);
check('scale_untouched_by_a_move', near(cfg.sx, 1) && near(cfg.sy, 1), true);
check('legacy_scale_not_written', cfg.legacy, null);
check('size_untouched_by_a_move', [im.w, im.h], [BASE.w, BASE.h]);
check('corner_lands_on_a_grid_node', onGrid(im.x) && onGrid(im.y), true);
// …at the node NEAREST the finger: 102 → 100 (24 steps), 152 → 150 (36).
@@ -201,9 +202,10 @@ await settle();
check('scaled_down', im.w < BASE.w, true);
check('uniform_no_stretch', near(im.w / im.h, BASE.w / BASE.h, 1e-9), true);
const cfg = await spaceCfg();
check('plan_scale_saved', near(cfg.k, im.w / BASE.w, 1e-5), true);
check('scaled_width_is_on_the_grid',
Math.abs((im.x + im.w) / PITCH - Math.round((im.x + im.w) / PITCH)) < 1e-6, true);
check('axis_scales_saved', near(cfg.sx, im.w / BASE.w, 1e-5)
&& near(cfg.sy, im.h / BASE.h, 1e-5), true);
check('proportional_scale_keeps_axes_equal', near(cfg.sx, cfg.sy, 1e-6), true);
check('scaled_width_is_on_the_grid', onGrid(im.x + im.w), true);
// …and the badge states the picture's REAL size through cell_cm
check('badge_matches_the_new_size', await page.evaluate(() => {
const c = window.__card;
@@ -221,7 +223,9 @@ check('reset_button_offered_once_moved', await q('.decorbar .bdreset'), 1);
await page.evaluate(() => { const c = window.__card; c._bdReset?.(); c.requestUpdate(); return c.updateComplete; });
await settle();
check('reset_puts_the_picture_back', await imageRect(), BASE);
check('reset_clears_the_fields', await spaceCfg(), { x: null, y: null, k: null });
check('reset_clears_the_fields', await spaceCfg(), {
x: null, y: null, sx: null, sy: null, legacy: null, angle: null,
});
check('reset_button_gone_again', await q('.decorbar .bdreset'), 0);
// ---------- 5b) the SELECT tool still pans right over the picture ---------
+10
View File
@@ -1,5 +1,7 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
// Reproduce the narrow wall-tablet/mobile dialog from the field report.
await page.setViewportSize({ width: 390, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
@@ -12,6 +14,14 @@ const res = await page.evaluate(async () => {
const dev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('light.') || e.startsWith('switch.')));
out.hasDev = !!dev;
c._infoCard = dev; await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
const footer = sr().querySelector('.infofooter');
const footerBox = footer?.getBoundingClientRect();
const footerButtons = [...(footer?.querySelectorAll('button') || [])];
out.footerActionsContained = !!footerBox && footerButtons.length >= 2 && footerButtons.every((button) => {
const rect = button.getBoundingClientRect();
return rect.left >= footerBox.left - 1 && rect.right <= footerBox.right + 1;
});
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
const body = sr().querySelector('hp-dialog .body');
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
+24
View File
@@ -57,6 +57,30 @@ const res = await page.evaluate(async () => {
c._clickDevice(new MouseEvent('click'), dev2());
const last = calls.at(-1);
out.lockFiltered = JSON.stringify(last) === JSON.stringify(['homeassistant', 'turn_on', [lights[0]]]);
// Legacy configs could store a standalone entity as its own external
// control. That self-reference must neither become a light source/group nor
// disappear behind ambiguous dialog UI. Its normal Toggle action still
// operates the entity, and an ON hood/fan still has working-state yellow.
const own = Object.keys(c.hass.states).find((eid) => eid.startsWith('switch.'));
out.hasStandaloneSwitch = !!own;
const self = own && {
id: 'self-control-regression', name: 'Hood', icon: 'mdi:fan',
space: c._space, area: room.area, entities: [own], primary: own,
bindingKind: 'entity', bindingRef: own, tapAction: 'toggle',
marker: { id: 'self-control-regression', binding: `entity:${own}`, controls: [own] },
};
if (self) {
await setSt({ [own]: 'off' });
c._clickDevice(new MouseEvent('click'), self);
out.selfControlUsesDirectToggle = JSON.stringify(calls.at(-1))
=== JSON.stringify(['homeassistant', 'toggle', own]);
c._openMarkerDialog(self); await c.updateComplete;
out.selfControlAbsentFromDialog = c._markerDialog?.controls.length === 0;
c._markerDialog = null;
await setSt({ [own]: 'on' });
out.selfSwitchStillShowsWorkingState = c._stateClass(self).includes('on');
}
return out;
});
checkAll(res);
+28 -4
View File
@@ -48,6 +48,9 @@ const res = await page.evaluate(async () => {
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
c._decorCommitDraft();
out.degenerateSkipped = c._decorList.length === 3;
c._decorDraft = { kind: 'rect', a: [g, g], b: [g * 5, g], pid: 1 };
c._decorCommitDraft();
out.flatRectSkipped = c._decorList.length === 3;
// 3b) живая плашка размера, пока фигуру ещё тянут (владелец 2026-08-04:
// «в редакторе подложки у линий писать длину, как при рисовании комнат в
// редакторе плана»). Та же .measurelabel, тот же formatLength/cell_cm.
@@ -76,10 +79,11 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
out.obliqueBadge = txt() === fmt(g * 5) + ' · 53.1°';
out.obliqueNotGreen = !!label() && !label().classList.contains('on45');
// прямоугольник и овал: длины у них нет, есть габарит «Ш × В»
// прямоугольник показывает габарит и чистую площадь
c._decorDraft = { kind: 'rect', a: [g * 4, g * 20], b: [g * 12, g * 26], pid: 3 };
await c.updateComplete;
out.rectBadgeIsSize = txt() === fmt(g * 8) + ' × ' + fmt(g * 6);
out.rectBadgeHasSizeAndArea = txt().startsWith(fmt(g * 8) + ' × ' + fmt(g * 6) + ' · ')
&& /(?:m²|ft²)$/.test(txt());
// отпустили — плашки нет
c._decorCommitDraft(); await c.updateComplete;
out.badgeGoneAfterRelease = !label();
@@ -181,19 +185,39 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
out.doubleClickOpensObjectDialog = c._decorShapeDialog?.id === 'dcprobe'
&& !!sr().querySelector('hp-dialog .dfill') === false;
c._decorShapeDialog = { ...c._decorShapeDialog, color: '#123456', width: 6.5 };
c._decorShapeDialog = { ...c._decorShapeDialog, color: '#123456', widthCm: 6.5 };
c._decorSaveShape(); await c.updateComplete;
const editedProbe = c._decorList.find((x) => x.id === 'dcprobe');
out.objectDialogSavesStyle = editedProbe?.color === '#123456'
&& editedProbe?.width === 6.5 && c._decorShapeDialog === null;
&& editedProbe?.width_cm === 6.5 && editedProbe?.width === undefined
&& c._decorShapeDialog === null;
c._decorSel = null;
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
c._decorTool = 'select'; await c.updateComplete;
out.decorRestored = c._decorList.length === decorBefore.length;
// Switching modes during a live move cancels the transaction and restores
// its start instead of leaving mutated geometry with a forgotten pointer.
const liveShape = c._decorList[0];
const liveBefore = JSON.parse(JSON.stringify(liveShape));
const liveSnapshot = c._geometrySnapshot();
c._decorMove = {
id: liveShape.id, start: [0, 0], orig: liveBefore, pid: 99,
moved: true, before: liveSnapshot,
};
c._curSpaceCfg.decor = c._decorList.map((shape) => shape.id === liveShape.id
? { ...shape, x: (shape.x || 0) + 0.2 }
: shape);
c._setMode('view'); await c.updateComplete;
const restoredLive = c._decorList.find((shape) => shape.id === liveShape.id);
out.modeSwitchCancelsLiveDecor = !c._decorMove
&& JSON.stringify(restoredLive) === JSON.stringify(liveBefore);
c._setMode('decor'); await c.updateComplete;
// 6) erase удаляет
const n0 = c._decorList.length;
c._decorTool = 'erase';
c._decorShapeDown({ stopPropagation() {}, preventDefault() {}, target: null, pointerId: 1 }, c._decorList[0]);
out.eraseAsksConfirmation = c._decorList.length === n0 && !!c._decorEraseConfirm;
c._confirmDecorErase();
out.eraseWorks = c._decorList.length === n0 - 1;
// 7) Esc-лестница: инструмент → select → выход
c._decorTool = 'line'; c._decorSel = null; await c.updateComplete;
+16 -8
View File
@@ -1,6 +1,7 @@
// Текстовый блок декора (правки владельца 2026-08-04):
// 1) выбора размера шрифта БОЛЬШЕ НЕТ — размер задаётся углами блока, а
// сохранённый старый `size` читается как начальный масштаб;
// 1) выбора размера шрифта БОЛЬШЕ НЕТ — размер задаётся углами блока и
// после явной правки хранится физически в `size_cm`; старый `size`
// до этого продолжает читаться без визуальной миграции;
// 2) многострочный текст: перевод строки сохраняется и рисуется, блок
// выравнивается по центру;
// 3) у выделенного блока есть угловые ручки (масштаб) и ручка поворота
@@ -117,42 +118,49 @@ const res = await page.evaluate(async () => {
const far = toScreen(rcx + hitR * 0.7, rcy); // > knobR, < hitR
const hitEl = sr().elementFromPoint(far.clientX, far.clientY);
out.fingerZoneStillCatchesTheHandle = !!hitEl?.classList?.contains?.('dthandle');
// линия выделена — рамки текста нет (это рамка ТЕКСТОВОГО блока)
// линия использует общую рамку, но только с двумя ручками концов
c._curSpaceCfg.decor = [...c._decorList, { id: 'dline', kind: 'line',
x1: 0.1, y1: 0.8, x2: 0.4, y2: 0.8, color: '#000000', width: 3 }];
c._cfgEpoch++; c._decorSel = 'dline'; c.requestUpdate();
await c.updateComplete; await sleep(40); await c.updateComplete;
out.noFrameOnALine = !sr().querySelector('.dtframe');
const lineFrame = sr().querySelector('.dtframe.dtlineframe');
out.lineGetsEndpointFrame = !!lineFrame
&& lineFrame.querySelectorAll('.dthandle.dtendpoint').length === 2
&& !lineFrame.querySelector('.dtrot');
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
// тянем угол: масштаб растёт пропорционально расстоянию от якоря
// тянем угол: физический размер растёт пропорционально расстоянию от якоря
const anchor = () => {
const sh = c._decorList.find((x) => x.id === 'dtm');
return [sh.x * 1000, sh.y * 1000];
};
const [ax, ay2] = anchor();
const mediumSizeCm0 = c._decorTextSizeCm(c._decorList.find((x) => x.id === 'dtm'));
const corner = frame()?.querySelector('.dthandle.dt-nwse') || stageEl();
ev('pointerdown', corner, ax + 40, ay2);
out.dragStarted = !!c._dtDrag && c._dtDrag.kind === 'scale';
ev('pointermove', stageEl(), ax + 80, ay2);
await c.updateComplete;
const scaled = c._decorList.find((x) => x.id === 'dtm');
out.cornerScales = Math.abs((scaled.scale || 0) - 2) < 0.01;
out.cornerStoresPhysicalSize = scaled.scale === undefined && scaled.size === undefined
&& Math.abs((scaled.size_cm || 0) - mediumSizeCm0 * 2) < 0.01;
out.fontFollowsScale = +attr('dtm', 'font-size') === 40;
ev('pointerup', stageEl(), ax + 80, ay2);
out.dragEnded = !c._dtDrag;
// старый `size` заменяется масштабом, который он означал (одна правда)
// старый `size` становится физическим `size_cm` только после явной правки
c._decorSel = 'dtl'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const [lx, ly] = [c._decorList.find((x) => x.id === 'dtl').x * 1000,
c._decorList.find((x) => x.id === 'dtl').y * 1000];
const legacySizeCm0 = c._decorTextSizeCm(c._decorList.find((x) => x.id === 'dtl'));
ev('pointerdown', frame()?.querySelector('.dthandle.dt-nwse') || stageEl(), lx + 50, ly);
ev('pointermove', stageEl(), lx + 100, ly);
await c.updateComplete;
const grown = c._decorList.find((x) => x.id === 'dtl');
out.legacySizeBecomesScale = grown.size === undefined && Math.abs((grown.scale || 0) - 3) < 0.01;
out.legacySizeBecomesPhysical = grown.size === undefined && grown.scale === undefined
&& Math.abs((grown.size_cm || 0) - legacySizeCm0 * 2) < 0.01;
ev('pointerup', stageEl(), lx + 100, ly);
// ручка поворота: шаг 5°, Shift — мимо шага
+17 -2
View File
@@ -205,8 +205,12 @@ const res = await page.evaluate(async () => {
ev('pointermove', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
await c.updateComplete;
const snapped = sofaNow();
out.sizeSnapsToTheGrid = near(((snapped.w * 1000) / PITCH) % 1, 0, 1e-6)
&& near(((snapped.h * 1000) / PITCH) % 1, 0, 1e-6);
const wCells = (snapped.w * 1000) / PITCH;
const hCells = (snapped.h * 1000) / PITCH;
// Normalised config is rounded to six decimals, so a mathematical cell 27
// may read back as 26.9999999. Compare with the nearest integer, not `% 1`.
out.sizeSnapsToTheGrid = near(wCells, Math.round(wCells), 1e-4)
&& near(hCells, Math.round(hCells), 1e-4);
ev('pointerup', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
await c.updateComplete;
out.cornerDragEnded = !c._dtDrag;
@@ -246,6 +250,13 @@ const res = await page.evaluate(async () => {
&& near(after.w, stored.w, 1e-12) && near(after.h, stored.h, 1e-12)
&& near(after.x, stored.x, 1e-12) && near(after.y, stored.y, 1e-12);
out.stillRenderedAfterRebuild = !!el(sofaId);
c._decorTool = 'select';
c._decorShapeDbl({ preventDefault() {}, stopPropagation() {} }, after);
await c.updateComplete;
out.objectDialogSelectsStoredSymbol = sr().querySelector(
'.body select.namein option:checked',
)?.value === stored.symbol;
c._decorShapeDialog = null;
// ================= 8. инертность и удаление ==============================
c._decorTool = 'furniture'; c._furnPalette = null; await c.updateComplete;
@@ -257,6 +268,10 @@ const res = await page.evaluate(async () => {
c._decorTool = 'erase'; await c.updateComplete;
ev('pointerdown', el(sofaId), 300, 300);
await c.updateComplete;
// Eraser is intentionally two-step now; the generic decor smoke verifies
// the dialog itself, while this furniture scenario confirms the action.
c._confirmDecorErase();
await c.updateComplete;
out.eraseRemovesIt = !!sofaId && !c._decorList.some((s) => s.id === sofaId);
// а в режиме просмотра мебель видна и не кликается
c._setMode('view'); await c.updateComplete; await sleep(30);
+43 -6
View File
@@ -49,11 +49,11 @@ Object.assign(out, await page.evaluate(async () => {
// тумблер локальный: конфиг не трогается
o.toggleIsLocal = c._serverCfg.settings.show_all === undefined;
// --- галка в диалоге у авто-устройства, кнопки «Удалить» нет -----------
// --- авто-устройство тоже можно полностью удалить с плана --------------
const ghost = c._devices.find((x) => x.id === d.id);
c._openMarkerDialog(ghost); await c.updateComplete;
o.checkboxOn = c._markerDialog?.hideFromPlan === true;
o.noDeleteForAuto = !sr().querySelector('hp-dialog .btn.danger');
o.deleteForAuto = !!sr().querySelector('hp-dialog .btn.danger');
// снимаем галку и сохраняем — маркер остаётся с hidden:false (анти-повтор)
c._markerDialog = { ...c._markerDialog, hideFromPlan: false };
await c._saveMarker(); await c.updateComplete;
@@ -62,13 +62,50 @@ Object.assign(out, await page.evaluate(async () => {
const back = c._devices.find((x) => x.id === d.id) || c._devices.find((x) => x.bindingRef === d.bindingRef);
o.deviceVisibleAgain = !!back && !back.hidden;
// --- у виртуального кнопка «Удалить» есть -------------------------------
// --- у виртуального кнопка «Удалить» есть; удаляется только выбранный ---
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual' };
c._markerDialog = { ...c._markerDialog, name: 'delete-one-a', binding: 'virtual' };
await c._saveMarker(); await c.updateComplete;
const virt = c._devices.find((x) => x.virtual);
c._openMarkerDialog(virt); await c.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 2));
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'delete-one-b', binding: 'virtual' };
await c._saveMarker(); await c.updateComplete;
const virtualA = c._devices.find((x) => x.virtual && x.name === 'delete-one-a');
const virtualB = c._devices.find((x) => x.virtual && x.name === 'delete-one-b');
c._openMarkerDialog(virtualA); await c.updateComplete;
o.deleteForVirtual = !!sr().querySelector('hp-dialog .btn.danger');
const savedConfirm = window.confirm;
window.confirm = () => true;
await c._deleteMarker(); await c.updateComplete;
window.confirm = savedConfirm;
o.deleteOnlySelectedVirtual = !!virtualA && !!virtualB
&& !(c._serverCfg.markers || []).some((m) => m.id === virtualA.id)
&& (c._serverCfg.markers || []).some((m) => m.id === virtualB.id);
// A re-added HA binding replaces its tombstone and receives a fresh layout
// position instead of inheriting the deleted marker's last coordinates.
const readd = c._devices.find((x) => x.bindingKind === 'device' && !x.hidden);
const oldPos = { s: readd.space, x: 0.913, y: 0.917 };
c._layout = { ...c._layout, [readd.id]: oldPos };
c._openMarkerDialog(readd); await c.updateComplete;
window.confirm = () => true;
await c._deleteMarker(); await c.updateComplete;
window.confirm = savedConfirm;
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = {
...c._markerDialog,
bindingMode: 'ha',
binding: `device:${readd.bindingRef}`,
name: readd.name,
};
await c._saveMarker(); await c.updateComplete;
const rebound = (c._serverCfg.markers || [])
.filter((m) => m.binding === `device:${readd.bindingRef}`);
const fresh = c._layout[readd.id];
o.readdReplacesTombstone = rebound.length === 1 && rebound[0].removed !== true;
o.readdUsesFreshPosition = !!fresh
&& (Math.abs(fresh.x - oldPos.x) > 1e-6 || Math.abs(fresh.y - oldPos.y) > 1e-6);
c._markerDialog = null; c._setMode('view');
return o;
}));
+11 -4
View File
@@ -165,12 +165,18 @@ checkAll({
resizeBeadIsAQuarter: rad.rszQuarter,
});
// ---- 7) the backdrop editor opens ON the picture tool ---------------------
// ---- 7) every Background session starts on Select; image has its own tool --
const armed = await page.evaluate(async () => {
const c = window.__card;
c._setMode('view'); await c.updateComplete;
c._setMode('decor'); await c.updateComplete;
const o = { tool: c._decorTool, movable: c._bdMovable, hasPicture: !!c._curSpaceCfg.plan_url };
const o = {
startsOnSelect: c._decorTool === 'select',
inertUntilPictureTool: !c._bdMovable,
hasPicture: !!c._curSpaceCfg.plan_url,
};
c._decorTool = 'backdrop'; c.requestUpdate(); await c.updateComplete;
o.movable = c._bdMovable;
// and the picture really moves under it: press inside the body, drag, release
const r = c._bdRect;
const before = { x: c._curSpaceCfg.plan_x ?? 0, y: c._curSpaceCfg.plan_y ?? 0 };
@@ -194,8 +200,9 @@ const armed = await page.evaluate(async () => {
return o;
});
checkAll({
backdropToolArmed: armed.tool === 'backdrop',
pictureMovableAtOnce: armed.movable,
backgroundStartsOnSelect: armed.startsOnSelect,
pictureInertUntilOwnTool: armed.inertUntilPictureTool,
pictureMovableUnderOwnTool: armed.movable,
dragMovedThePicture: armed.moved,
});
+3 -2
View File
@@ -38,8 +38,9 @@ const out = await page.evaluate(() => {
'climate.trv': { state: 'heat', attributes: {} },
}) === '';
// горящая лампа устройства желтит значок, даже если primary — не она
const lamp = { id: 'l', primary: 'sensor.lamp_power', entities: ['sensor.lamp_power', 'light.lamp'], marker: null };
// Функциональная роль настоящей лампы желтит значок. Auxiliary light у
// soundbar/media-player, наоборот, отсечён системным role-resolver тестом.
const lamp = { id: 'l', primary: 'light.lamp', entities: ['sensor.lamp_power', 'light.lamp'], marker: null };
o.litLightWins = cls(lamp, {
'sensor.lamp_power': { state: '5' },
'light.lamp': { state: 'on' },
File diff suppressed because one or more lines are too long
+523 -209
View File
File diff suppressed because one or more lines are too long
+42 -16
View File
@@ -47,6 +47,8 @@ houseplan-card/
initial focus, Escape close event and restore-focus session. Focus sessions
are scoped to a card shadow root so nested dialogs return to their parent
trigger and dialog replacement still returns to the original outside opener.
Its footer wrapper is a full-width slot item: HA lays the footer slot out as
flex, so flattening that wrapper would shrink action rows to their content.
## Coordinate system
@@ -84,7 +86,8 @@ Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the
The same tooltip includes the formatted clean-floor area (inner contour for
thick walls), and View hover gives every room an accent/brightness highlight.
- Icon state classes: on (yellow), open (orange: cover/valve/lock/binary_sensor
of problem classes), unavail (transparency). Yellow remains on the marker in
of problem classes), unavail (transparency, also used by a powered-down
media endpoint). Yellow remains on the marker in
source-glow fill mode: a light pool is spatial information, not a replacement
for the universal working-state plate.
@@ -116,7 +119,7 @@ Room drawing shows a live **ruler** (`segmentCm` +
`cell_cm` (default 5 cm per grid cell).
`config.markers[]`: `{id, binding:'device:<id>'|'entity:<eid>'|'virtual', space?, area?, hidden?,
`config.markers[]`: `{id, binding:'device:<id>'|'entity:<eid>'|'virtual', space?, area?, hidden?, removed?,
name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. A hybrid: auto-discovered HA devices
appear on their own; a marker with `binding=device:<id>` overrides them (metadata/rebinding/hiding),
`entity:<eid>` — for groups/helpers, `virtual` — a manual icon without HA. The marker id = device_id /
@@ -125,16 +128,26 @@ references and duplicates by name|area. Manual files: transactional HTTP upload
(staging `up_*` folders promoted on save), served via signed
`/api/houseplan/content/files/…` urls.
`removed:true` is a binding tombstone, not a renderable marker. It claims an
HA binding against automatic discovery while intentionally exposing that same
binding to the Add picker. A device tombstone excludes all data of that device;
an entity tombstone excludes the standalone entity binding but does not mutate
the same entity out of a still-live parent device. Runtime-filtered references
such as `controls` remain persisted and become active again after re-add.
Re-adding replaces the tombstone; virtual markers need no tombstone because
they have no discovery source.
## Server-side configuration (current shape, v1.51+)
`.storage/houseplan.config` (Store):
```json
{ "spaces": [{ "id","title","plan_url","plan_aspect",
"plan_x","plan_y","plan_scale", // optional, docs/BACKDROP.md
"plan_x","plan_y","plan_scale_x","plan_scale_y","plan_angle",
"plan_scale", // legacy optional fallback, docs/BACKDROP.md
"view_box":[4],
"rooms":[{"id","name","area","poly|x/y/w/h","open_to","settings"}],
"openings":[…], "decor":[…], "settings":{…} }],
"markers": [{ "id","binding":"device:<id>|entity:<eid>|virtual","hidden",
"markers": [{ "id","binding":"device:<id>|entity:<eid>|virtual","hidden","removed",
"name","icon","display","controls","is_light","tap_action",
"room_id","pdfs",… }],
"settings": { "exclude_integrations":[], "group_lights":true,
@@ -144,14 +157,16 @@ references and duplicates by name|area. Manual files: transactional HTTP upload
All coordinates are **normalized (0..1 of the canvas)**; the canvas is always
**square** (v1.48.0), render space `NORM_W × NORM_W` (1000×1000). A space has no
proportions of its own — `plan_aspect` is the IMAGE's ratio, used to letterbox
it centred on the square; the optional `plan_x`/`plan_y`/`plan_scale` then move
and uniformly scale that rectangle (`planRect`, docs/BACKDROP.md), and their
absence is the centred default exactly. The schema bounds geometry to ±4 with strictly
it centred on the square; optional `plan_x/y`, independent `plan_scale_x/y`
and `plan_angle` then transform that rectangle (`planRect`, docs/BACKDROP.md).
Legacy `plan_scale` feeds both axes, and the absence of every transform field
is the centred default exactly. The schema bounds geometry to ±5000 with strictly
positive sizes (HP-1501/1502). `device_overrides`/`virtual_devices` are long
gone — markers carry everything, `marker.hidden` is the explicit
"hide from plan" flag seeded once by the old filter (docs/FILTERING.md).
gone — markers carry everything. `marker.hidden` is the explicit reversible
"hide from plan" flag seeded once by the old filter; `marker.removed` is the
minimal delete tombstone (docs/FILTERING.md).
Layout v2: `{device_id | rl_<roomId>: {"s": space, "x", "y"}}` (normalized,
bounded ±4). Plan files: `<config>/houseplan/plans/<space>.<token>.<ext>`
bounded ±5000). Plan files: `<config>/houseplan/plans/<space>.<token>.<ext>`
(copy-on-write, never overwritten), served via signed
`/api/houseplan/content/plans/_/<name>` urls; growth is bounded by store
quotas, nothing is ever deleted for being old (docs/SCOPE.md).
@@ -180,7 +195,8 @@ All committed plan-geometry mutations enter one named 50-command Undo/Redo stack
Ctrl+Shift+Z/Ctrl+Y and the toolbar buttons use the same stack; a new mutation after Undo drops
the redo branch. The local stack survives the server echo of its own writes, but is cleared when
a newer external config revision is adopted. Positional placement is always quantized to the plan
grid. Shift remains an angular precision modifier only and cannot create off-grid coordinates.
grid. Shift may alter a gesture's geometry (square/circle creation, independent
resize axes or free rotation), but it cannot create off-grid coordinates.
**A line is never an entity of its own (v1.19.0).** Nothing is persisted while you draw: an
outline you never close leaves no trace. Walls are *derived* from the room outlines by
@@ -222,6 +238,7 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
| `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/trail/delete` | `marker_id` | `{ok, removed}` — erase current/previous runs after marker deletion |
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
| `houseplan/plan/optimize` | `config`, `layout`, both expected revisions | crash-resumable two-store commit + one-deep backup |
| `houseplan/plan/optimize_undo` | both expected revisions | restores backup only before any later edit |
@@ -405,11 +422,20 @@ hash falls back to the default.
## Additions v1.28–v1.41 (2026-07-24)
- **Decor layer** (`space.decor[]`, v1.33): purely visual shapes
(line/rect/ellipse/text, normalized coords, per-shape style) drawn in the
SVG right above the background image and BELOW rooms; pointer-events only
inside the Background editor. Validated by `DECOR_SCHEMA` (vol.Any of the
four kinds).
- **Decor layer** (`space.decor[]`, v1.33; unified editor 2026-08-07): purely
visual line/rect/ellipse/text/furniture shapes, normalised geometry and
physical per-shape style. `src/editors/decor/types.ts` is the typed persisted
union; `geometry.ts` owns cm↔render conversion, oriented boxes and the
decor+room magnet; `hp-color-opacity` is the shared colour/alpha control.
`houseplan-card.ts` still owns orchestration, but every kind uses one
selection/transform/history pipeline. `DECOR_SCHEMA` accepts canonical
`width_cm`, text `size_cm`, opacity/fill fields and legacy width/text-size
representations for read compatibility.
- **Plan image transform**: `planRect()` resolves the fitted image plus
`plan_x/y`, independent `plan_scale_x/y` and `plan_angle`; legacy
`plan_scale` feeds both axes. The image is interactive only in its own
Background tool, rotated corners contribute to content bounds, and the
static card uses the same model. See `DECOR-EDITOR.md` and `BACKDROP.md`.
- **Glow fill** (v1.35+): `fill_mode: 'glow'` paints every room with
`fill_colors.glow_base` and renders per-source radial gradients clipped by
a per-light `clipPath` = zone polygons + doorway sectors, each contour a
+56 -254
View File
@@ -1,270 +1,72 @@
# The backdrop picture — the spec (source of truth)
# Plan image backdrop
*Owner-approved 2026-08-04. Companion to docs/CANVAS.md (the plane the
picture lives on) and docs/RESIZE.md (the other transform frame).*
Status: current in v1.60.0. The complete Background editor contract is in
`DECOR-EDITOR.md`.
Until v1.58.0 an uploaded plan image was nailed to the canvas: centred, at
its own proportions, filling the square. If it did not line up with the
rooms you had drawn — or with the rooms you were about to draw — the only
recourse was to edit the file. And because it filled the canvas, it also
decided where the opaque **paper** was, which quietly made a picture plan
and a drawn plan two different kinds of thing.
## Placement model
This closes both. The picture is now **one of the objects on the plane**:
it can be moved and scaled like anything else, it counts as content for
the frame, and it no longer makes paper of its own.
The source image is first fitted proportionally into the square plan canvas by
`fitInSquare(plan_aspect, NORM_W)`. The optional transform is then applied:
---
| Field | Meaning | Default |
|---|---|---:|
| `plan_x`, `plan_y` | top-left offset from the fitted rectangle, normalised by `NORM_W` | `0` |
| `plan_scale_x`, `plan_scale_y` | independent width/height multipliers | `1` |
| `plan_angle` | rotation around the transformed rectangle centre | `0°` |
| `plan_scale` | legacy uniform fallback for both axes | `1` |
## §1 Model — three optional numbers
`planRect()` is the single reader used by the full card, static card and content
bounds. New writes remove `plan_scale`; **Оптимизировать планы** converts it
losslessly to both axis fields.
A space gains three optional fields, next to `plan_url` / `plan_aspect`:
## Editor behaviour
| Field | Meaning | Units | Range |
| --- | --- | --- | --- |
| `plan_x` | offset of the image's **top-left corner** from its default place | normalised (as rooms, decor, openings) | ±`CANVAS_LIMIT` (±5000) |
| `plan_y` | the same, vertically | normalised | ±`CANVAS_LIMIT` |
| `plan_scale` | **one** uniform multiplier for both sides, anchored at the (already offset) top-left corner | multiplier | `0.01 … 100` |
The Background editor opens on **Select**, never on the image tool. The image
is interactive only under **Plan backdrop**:
There is **no rotation** and no independent width/height: the picture keeps
its own aspect ratio for ever, which is what makes "scale" a single number.
- body drag moves it;
- four corner handles preserve ratio by default;
- `Shift` allows independent axes;
- the upper handle rotates in 5° steps, or freely with `Shift`;
- double click opens numeric width, height and angle;
- `Esc` restores the transform at pointer-down;
- release creates one named command in the shared 50-step history;
- Reset removes all six transform fields and is itself undoable.
**The absence of all three is exactly the pre-v1.58.0 behaviour.** No
migration runs, nothing is written into anybody's store, and a plan that has
never been through the backdrop editor renders bit-identically. Junk (a
string, `NaN`, a zero or negative scale) is treated as absence, not as an
error — the same reading rule the rest of the geometry uses.
In the Background editor the image is at opacity `0.5` under every other tool
and cannot receive pointer events from the controller. Under Plan backdrop it
is opaque. View and the other editors always show it at opacity `1`.
One function owns the arithmetic, `planRect()` in `src/space-geometry.ts`:
Move and resulting top-left coordinates are grid-bound. Width and height are
quantised when resized or entered numerically. There is no positional modifier
that bypasses the grid.
```
base = fitInSquare(plan_aspect, 1000) // the centred default
rect.x = base.x + plan_x * 1000
rect.y = base.y + plan_y * 1000
rect.w = base.w * plan_scale
rect.h = base.h * plan_scale
## Rendering and content
The image is purely decorative and never changes rooms, walls, openings,
devices, Glow or sun geometry. It is still a content item for fit/pan bounds.
For a rotated image all four rotated corners contribute to those bounds.
Layer order, top to bottom:
```text
devices and room labels
sun / walls / openings / rooms / decor
plan image
room-shaped paper
scene background
```
`spaceModels()` calls it, so **every** consumer — the full card, the static
`houseplan-space-card`, the kiosk, the content frame — reads the same
rectangle. The card's model cache key (`_cfgFingerprint`) carries the three
fields, so a drag invalidates the memo.
The paper remains room-shaped; an image without rooms does not create opaque
paper behind itself.
`validation.py` mirrors the ranges (`PLAN_SCALE_MIN` / `PLAN_SCALE_MAX`
alongside `CANVAS_LIMIT`). Detaching the image (space dialog → «Нарисую
комнаты вручную») deletes all three: there is nothing left for them to
describe, and a stale transform must not silently apply to the next upload.
---
## §2 The transform frame
**Where.** The **backdrop editor** (`mode: 'decor'`, «Редактор подложки»).
The frame is on screen as soon as that editor opens (tools **select** and
**«Картинка-подложка»**); the drawing tools own the drag, so the frame
steps aside for them. Nowhere else: View, the Plan editor, the Device
editor and the kiosk never touch the picture. A space with no image never
grows a frame, and its toolbar is unchanged.
**What.** A dashed outline on the picture's rectangle plus four corner
handles. **Two radii, one gesture:** the HIT circle is 2 % of the visible
view — finger-sized at any zoom, the same rule the vacuum fit frame uses —
and is invisible; the bead you actually see (`.bdknob`) is a **quarter** of
it (owner, 2026-08-05: «уменьшить в 4 раза… они постоянно гигантские»). The
clickable area is unchanged; only the ink shrank, so the frame stops
covering the picture it frames. The same split now governs the text block
(docs/LIVE-TEXT.md §3), the room-resize corners and the vacuum fit. The
outline never takes a pointer; the handles do, with `setPointerCapture` and
`nwse-resize` / `nesw-resize` cursors. Under the picture's own tool the
stage cursor is `grab`, and `grabbing` while a gesture is live.
**Move** — press anywhere inside the picture and drag, **under the
«Картинка-подложка» tool**. Why a tool and not just the select tool: the
picture's body is most of the screen, and claiming it would take away the
one-finger pan of the whole plane, which the owner asked for on 2026-08-04
(«таскать план при любом масштабе») and `smoke_pan_any_zoom` guards. The
corner handles are precise targets and need no such protection — they are
live whenever the frame is. The gesture is also only started INSIDE the
rectangle, so pressing beside the picture pans even under its own tool.
**…and that tool is ARMED when the editor opens** (owner, 2026-08-05: «не
получается двигать картинку-подложку в режиме редактора подложки»). The
frame is drawn on open, which promises a draggable picture, while the
promise was only kept once the user found a tool they had no reason to look
for. So `_setMode('decor')` selects `backdrop` instead of `select` whenever
the space HAS a picture — the editor is named after it. Nothing else
changes: `select` is one click away and still leaves the body to the pan, a
space with no picture still opens on `select`, and Esc still returns to
`select`. This is a discoverability fix, not a new gesture.
**Nothing may rescale the stage mid-gesture.** Two things wanted to, and
both moved the picture away from the finger before they were stopped:
* the picture is a content item, so dragging it GROWS the editor's content
frame, which rescales the view — `_frameOf()` therefore returns the
memoized frame untouched while `_bdDrag` is live, and catches up on
release;
* «Вернуть картинку» appears the moment the picture has moved, and the
toolbar sits above the stage — so it is withheld until the gesture ends.
**Scale** — pull a corner. Uniform, about the **opposite** corner, which
stays exactly where it was (the mechanic borrowed from the vacuum
calibration panel). `k = max(|dx| / base.w, |dy| / base.h)`, so the gesture
follows whichever axis you pulled harder, and both sides then follow `k`.
**Nothing else moves.** Rooms, openings, devices, decor and room labels are
untouched by both gestures — this is a picture-only transform, and the
smoke asserts the room geometry is byte-identical afterwards.
**Undo** is the «Вернуть картинку» button in the backdrop toolbar: it
appears only once the picture HAS been moved, and it deletes the three
fields (back to centred, own size).
---
## §3 THE PAPER IS THE ROOMS — and only the rooms
*This is a behaviour CHANGE, deliberately.*
The opaque "paper" exists so the scene background — `bg_color`, or the
`daynight` sky (docs/SUN.md) — never bleeds through the plan itself. Until
v1.58.0 it had two forms: the ROOM CONTOURS for a drawn plan, and **the
image rectangle** for a picture plan.
The second form is gone. The paper is now `paperRoomShapes(space.rooms)`
in every case (or `paperRoomShapesWithWalls` when a space carries wall
thickness — docs/WALL-THICKNESS.md §4 — so the sheet grows under shared
slabs and the scene never shows through a hatched body):
* one opaque shape per room, in exactly the room's own geometry — an
L-shaped house or a pair of detached buildings never grows a bounding
rectangle;
* an empty space has **no paper at all**, image or no image;
* its colour is unchanged (`styles.ts .hp-paper`): white on a hand-drawn
plan (`.stage.noplan`), the theme card background where an image is
attached in **View**; every editor (plan / devices / decor) forces white
under the grid even with a backdrop, so the drawing sheet stays the same
sheet as a hand-drawn plan. `daynight` dims it via the `.zoomwrap`
brightness filter only — its alpha stays 1.
**Layer order**, top to bottom, in both renderers:
```
devices / labels
sun wedges and rays
walls, openings, room fills, decor
THE PICTURE ← above the paper, below the geometry
the paper (room contours)
the scene background (bg_color / the daynight sky)
```
**The consequence, and the owner knows it:** a picture with transparency
over a space with no rooms drawn shows the scene background through itself.
That is the honest reading of "the paper belongs to the rooms" — draw the
rooms and the paper appears under them.
---
## §4 The picture is content
`contentItems()` has always counted the image rectangle as one content item
alongside the rooms; it now counts the **transformed** one. So the frame,
«Вписать всё», the zoom floor and the pan slack (docs/CANVAS.md §4–§5) all
follow the picture wherever it is dragged, and a picture scaled down to a
quarter tightens the frame around it instead of framing empty plane.
`iconUnit()` deliberately does **not** count it: an icon is a percentage of
the drawn plan (docs/CANVAS.md §6), and scaling the backdrop must not
resize every marker on it.
---
## §5 Snap
Both gestures obey the canvas snap contract (docs/CANVAS.md §9):
| Gesture | What is snapped |
| --- | --- |
| move | the resulting **top-left corner** — the anchor, not the delta, so one drag is enough to put a legacy off-grid picture onto the lattice |
| scale | the dragged corner **along the picture's longer side**; the scale is then read back off it |
The fixed corner of a scale keeps its (on-grid) place and the long side
lands on a node. The short side follows from the aspect ratio and generally
does **not** — that is what "uniform, no stretch" costs, and it is the
right trade: a picture whose proportions drift is a broken picture.
There is no modifier that suspends positional snapping (§9.4). The
`±CANVAS_LIMIT` clamp rides along — `_snap()` and `clampCanvasN` are the
only bound either gesture has.
---
## §6 Live measurements
While the picture is dragged or scaled, one badge rides its centre with the
picture's **real size**, width × height, through the space's `cell_cm` and
the HA unit system (metres or feet). It is the same `_fmtLen`
(`segmentCm` → `formatLength`) and the same `.measurelabel` element the
wall ruler, the opening shoulders and the room resize use — the card states
a length exactly one way.
---
## §6b One tool at a time — a drawing tool owns the canvas
The backdrop editor has seven tools, and they are **exclusive**: only
**select** talks to an existing shape, only **erase** deletes one, only
**«Картинка-подложка»** grabs the picture's body, and the four drawing
tools (**line**, **rect**, **ellipse**, **text**) do nothing but create.
The rule exists because of a concrete failure (owner, 2026-08-04): *«нельзя
поставить начало линии на конец другой — при клике на конец выделяется
первая линия, а не начинает рисоваться новая»*. The shape's own
`pointerdown` handler ran first, stopped the event and did nothing under a
drawing tool, so the press never reached the stage and no draft was born —
exactly on the one spot where a new line most wants to start, since §5's
snap anchors include every line end.
So, under a drawing tool (and under the picture tool):
* `.dshape` is `pointer-events: none` — an existing figure is not a target
at all, and the cursor is the stage's;
* `_decorShapeDown` returns before `stopPropagation` for any tool that is
not select/erase, so a synthetic press cannot swallow the gesture either;
* `_decorPointerDown` only looks for a shape under the pointer for
select/erase, and otherwise starts the draft at the pressed point;
* the transform frame's corner handles stay off (§2) — they were the first
thing taught not to swallow the first point of a line.
Selecting, moving and the text dialog's double-click therefore live in the
select tool only. `demo/smoke_decor.mjs` asserts both halves: a press on an
existing line starts a new line **from that end** under the line tool, and
still selects and grabs it under select.
---
## §7 What is deliberately NOT done
* **No rotation.** A skewed scan is a scan to re-take, not a plan to skew
the whole editor's maths for.
* **No non-uniform scale.** See above: the picture keeps its ratio.
* **No numeric fields** in the space dialog. The frame is the interface;
the numbers exist for the config, not for typing.
* **No per-room clipping of the picture.** It is one rectangle.
* **No transform for the sun's paper/wedges.** Those follow the rooms, and
now so does the paper — one rule, no second geometry.
---
## §8 Where it lives
## Ownership
| Concern | File |
| --- | --- |
| the rectangle (`planRect`, `PLAN_SCALE_MIN/MAX`), content items | `src/space-geometry.ts` |
| the frame, the gestures, the live badge, the reset | `src/houseplan-card.ts` (`_bd*`, `_renderBackdropFrame`) |
| the frame's look, the paper, the cursors | `src/styles.ts` (`.bdframe`, `.hp-paper`) |
| the static card's copy of the paper + picture order | `src/space-render.ts` |
| the stored ranges | `custom_components/houseplan/validation.py` |
| unit coverage | `test/backdrop.test.mjs` |
| DOM coverage | `demo/smoke_backdrop.mjs`, `demo/smoke_bg_color.mjs` §11 |
| screenshots | `demo/shot_backdrop.mjs` |
|---|---|
| fitted/transformed rectangle and rotated bounds | `src/space-geometry.ts` |
| gestures, frame, numeric dialog, history/reset | `src/houseplan-card.ts` |
| full-card SVG image | `src/houseplan-card.ts` |
| static-card SVG image | `src/space-render.ts` |
| schema ranges | `custom_components/houseplan/validation.py` |
| legacy conversion | `src/plan-optimizer.ts` |
+7 -5
View File
@@ -354,7 +354,7 @@ wall runs diagonally is broken geometry, not a tidy plan:
| Element | Where |
| --- | --- |
| the backdrop picture: move (its top-left corner) and corner scale (the dragged corner, along the longer side) | `_bdMove` → `_snap` / `snapToGrid` (docs/BACKDROP.md §5) |
| the backdrop picture: move (its top-left corner) and proportional/independent corner scale | `_bdMove` → `_snap` / `snapToGrid` (docs/BACKDROP.md) |
| room vertices (draw tool) | `_markupClick` → `_snap` |
| split tool's interior vertices | `_splitClick` → `_snap` |
| resize edge drag / corner scale | `_rszMove` → `_snap` |
@@ -394,9 +394,11 @@ Three things were fixed here besides the new coverage:
There is no free-position mode. `Shift` never suspends coordinate
snapping: free anchors always land on grid nodes, and wall-bound objects
always remain projected and quantised along their wall. `Shift` is
reserved for explicitly angular controls (fine decor rotation and the
compass step) and cannot create an off-grid object.
always remain projected and quantised along their wall. It modifies only
the current gesture: square/circle creation, independent resize axes, free
decor/backdrop rotation, the compass step, or bypassing the furniture wall
magnet while the ordinary decor/room/grid magnet remains active. It cannot
create an off-grid object.
### 9.5 «Оптимизировать планы» — explicit whole-plan maintenance
@@ -503,7 +505,7 @@ undo because a grid projection is not invertible.
| static card `aspect-ratio` + `viewBox` from `space.vb` | the static card frames the square | `spaceFrame()` — same content frame as the full card |
| `validation.py` `+/-4`, `_EXTENT <= 4`, decor `-1..2`, opening `length <= 1` | the square plus slack | §3 |
| `safeViewBox` fallback `[0,0,1,1]` | a broken `view_box` means the square | kept — it is only the last-resort hint (§4) |
| `fitInSquare` (image placement) | image is centred in the square | **kept** — it defines the image's own rectangle in canvas units, which is exactly what §4 wants as a content item. Since v1.58.0 it is only the DEFAULT placement: `planRect()` adds the owner's `plan_x`/`plan_y`/`plan_scale` on top, and the moved rectangle is what §4 counts (docs/BACKDROP.md) |
| `fitInSquare` (image placement) | image is centred in the square | **kept** — it defines the image's own rectangle in canvas units, which is exactly what §4 wants as a content item. It is only the DEFAULT placement: `planRect()` adds `plan_x/y`, per-axis scale and angle on top (with legacy `plan_scale` as fallback), and the transformed corners are what §4 counts (docs/BACKDROP.md) |
| image plan papers the image rect | the picture IS the sheet | **removed** in v1.58.0 — the opaque paper is the room contours in every case, and the picture is drawn on top of it (docs/BACKDROP.md §3) |
| `_spaceH` / `_decorH` = `NORM_W` | the canvas is square | **kept** — this is the coordinate system's aspect, not a frame |
| `_gridPitch = NORM_W / GRID_N` | grid pitch is tied to the canvas unit | **kept** — the pitch is the real-world cell (`cell_cm`), it must not change with the plan's size |
+78 -1
View File
@@ -1,6 +1,83 @@
# Changelog
## Unreleased (dev)
## v1.60.0 — 2026-08-07
- **Audit hardening.** Deleting one virtual marker no longer removes its
neighbours; real `v_*` ids remain positionable; deletion tombstones are
hidden for old clients and binding-scoped for entity members of live HA
devices. Runtime-filtered controls are no longer destructively saved. Trail
deletion is transactional. Optimisation clamps migrated physical styles,
validates `cell_cm`, preserves invalid legacy transforms for manual repair
and reports model-version bookkeeping honestly. Flat shapes are rejected,
furniture selectors initialise reliably, rotated resize keeps its fixed
corner, mode switches cancel live transforms, and the demo bundle is rebuilt
from the same source as production. English device names containing
`thermometer` now resolve as temperature sensors instead of matching the
broader meter rule.
- Auto icons now update immediately after rebinding and can be explicitly
pinned; imperial stroke-field bounds use inches; decor magnet geometry is
memoised; Undo and Redo share the same live-gesture boundary.
- A marker can no longer list its own bound entity (or an entity of its bound
device) as an external light control. Legacy self-controls are ignored immediately;
marker Save removes them, and plan optimisation also cleans a directly bound
entity self-reference. The explicit `is_light` switch
remains the only way to classify that bound relay as a light source. Active
fans/hoods still use the yellow working-state plate without entering Glow,
Light fill or room light statistics.
- **The Background editor now has one coherent transform and style system.**
Every decor kind can be selected, moved, resized proportionally (Shift for
independent axes), rotated and edited numerically; lines expose endpoint
handles. Stroke/text and fill have independent colour/opacity; stroke width
and text size are canonical physical cm/in values. Rectangle drafts show area and ovals show radii. Drawing
magnetises only to decor and room geometry. Decor and the plan image keep
both dimensions on the grid even during proportional resize and use
the named 50-step Undo/Redo stack. The image has an exclusive tool, fades to
0.5 elsewhere in this editor, supports independent axes/rotation and remains
fully opaque outside it. Legacy styles/transforms remain visually stable and
migrate only via explicit plan optimisation.
- **Devices can now be deleted from the plan, not merely hidden.** Delete sits
beside Hide/Show and requires confirmation. A deleted HA binding is excluded
from rendering, Show-hidden ghosts, LQI, climate, room sources, light
resolution/Glow/statistics, controls, openings and live text, while remaining
available in Add. Re-adding replaces its minimal discovery tombstone and
starts with a fresh position. Virtual markers are removed outright; layout,
attachments, activity runtime and vacuum trails are cleaned, and stale-tab
drag updates cannot resurrect the old position.
- **Device-dialog footer spans the dialog again.** The shared `hp-dialog`
footer is now a full-width slot item instead of exposing its action row as a
shrink-to-fit flex child. The divider no longer starts halfway across the
modal, Hide stays at bottom-left, and Cancel/Save stay at bottom-right.
- **Auto device icons keep their label in the editor.** When a marker has no
explicit icon override, the HA icon picker now receives the effective
auto-derived icon as its display value, so the field shows both the glyph
and `mdi:*` name instead of a nameless glyph. The stored override remains
empty until the user actually selects an icon.
- **Media-player power is no longer mistaken for work.** `media_player`
markers now stay neutral for `on`, `idle`, `playing`, `paused` and other
transport states; an explicit `off` uses the same faded presentation as
`unknown` / `unavailable`.
This is a domain-level rule for TVs, receivers, speakers and soundbars, not
a model exception. Mixed media entities fade only when none of them is
currently available and powered.
- **Returning to a long-suspended View is stable.** In normal View (not kiosk),
transient zero/partial ResizeObserver boxes no longer overwrite the saved
viewport. After a genuinely long background sleep the plan is revealed only
after the stage size is measurable and quiet, then zoom and centre are
applied in one frame. Critical wall fill/stroke attributes also live directly
on the SVG paths, so a warm Lovelace re-mount cannot show the browser's black
default paint while component styles are being restored. Editors, kiosk and
quick tab switches keep their existing immediate behaviour.
- **Device-info actions stay inside narrow dialogs.** The Edit, Open in HA and
Close actions now wrap as complete buttons when the small HA dialog cannot
fit one row; reduced mobile padding preserves useful width. The leading Edit
action can no longer be pushed beyond the left edge of the modal.
## v1.59.2 — 2026-08-07
+81 -1
View File
@@ -6,7 +6,87 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## Не выпущено (dev)
## v1.60.0 — 2026-08-07
- **Исправления по аудиту.** Удаление одного виртуального маркера больше не
удаляет соседние; реальные id вида `v_*` можно перемещать; tombstone скрыт
даже для старых клиентов и действует на привязку, а не вырезает entity из
живого родительского HA-устройства. Временно неактивные controls больше не
стираются при обычном сохранении. Удаление trail стало транзакционным.
Оптимизация ограничивает мигрируемые физические размеры, валидирует
`cell_cm`, не выбрасывает некорректный legacy transform и честно отделяет
bump версии модели от миграции. Нулевые фигуры отбрасываются, список мебели
выбирает сохранённый символ, resize повернутого объекта сохраняет опорный
угол, а смена режима отменяет незавершённый transform.
Английские имена устройств со словом `thermometer` теперь распознаются как
датчики температуры, а не попадают под более общее правило счётчиков.
- Автоиконка обновляется сразу после смены привязки и может быть явно
закреплена; границы толщины в imperial-режиме показываются в дюймах;
геометрия магнита кешируется; Undo и Redo одинаково работают с живым жестом.
- Маркер больше не может записать собственную entity или сущность привязанного
устройства в список других управляемых источников света. Старая самоссылка сразу
игнорируется; сохранение маркера удаляет её, а оптимизация планов также чистит
прямую самоссылку `entity:*`;
считать само реле источником света можно только явным флагом. Включённая
вытяжка/вентилятор по-прежнему получает жёлтую подложку работающего устройства,
но не участвует в Glow, заливке «Свет» и статистике освещения комнаты.
- **Редактор подложки получил единую систему стилей и трансформаций.** Любой
объект декора можно выбрать, переместить, пропорционально масштабировать
(`Shift` — разные оси), повернуть и точно изменить числами; у линии есть
ручки концов. Контур/текст и заливка имеют независимые цвет и прозрачность,
толщина и размер текста хранятся физически в см/дюймах, прямоугольник показывает площадь, а
овал — радиусы. Магнит использует только декор и контуры комнат. Декор и
картинка сохраняют обе стороны на сетке даже при пропорциональном размере и
используют общий именованный Undo/Redo на 50 шагов. У картинки
отдельный инструмент, непрозрачность 0.5 под остальными инструментами,
независимые оси и поворот; вне редактора она полностью непрозрачна. Старые
стили/трансформации не меняются визуально и мигрируют только явной
оптимизацией планов.
- **Устройства теперь можно действительно удалить с плана, а не только
скрыть.** Кнопка «Удалить» находится рядом со «Скрыть»/«Показать» и требует
подтверждения. Удалённая HA-привязка не рисуется, не показывается среди
скрытых и не участвует в LQI, климате, источниках комнаты, свете/Glow,
статистике, controls, проёмах и живом тексте, но снова доступна через
«Добавить». Повторное добавление заменяет служебную запись и начинает с новой
позиции. Виртуальный маркер удаляется полностью; layout, вложения, runtime
активности и следы пылесоса очищаются, а поздний drag из старой вкладки не
способен воскресить позицию.
- **Нижняя панель диалога устройства снова занимает всю ширину.** Общий
`hp-dialog` теперь передаёт footer как полноширинный элемент, а не как
сжимаемую по содержимому flex-строку. Разделитель больше не начинается с
середины окна, «Скрыть» остаётся слева, «Отмена» и «Сохранить» — справа.
- **У автоматической иконки больше не пропадает подпись в редакторе.** Если у
маркера нет явной замены иконки, селектор HA получает вычисленную автоиконку
как отображаемое значение и показывает и сам значок, и его имя `mdi:*`.
Сохранённое переопределение при этом остаётся пустым, пока пользователь сам
не выберет иконку.
- **Питание медиаплеера больше не считается работой.** Маркеры
`media_player` остаются нейтральными в состояниях `on`, `idle`, `playing`,
`paused` и других состояниях транспорта; явный `off` использует то же
приглушённое отображение, что `unknown` / `unavailable`. Это правило домена
для телевизоров, ресиверов, колонок и саундбаров, а не исключение для
конкретной модели. Составной медиамаркер бледнеет, только когда среди его
сущностей нет доступной и включённой.
- **Возврат к давно свёрнутому Просмотру больше не дёргает план.** В обычном
Просмотре (не киоск) нулевые и промежуточные размеры `ResizeObserver` больше
не перезаписывают сохранённый viewport. После действительно долгого сна
вкладки план открывается, когда размер сцены уже измерен и успокоился, а
масштаб и центр применяются одним кадром. Критические параметры заливки и
контура толстых стен теперь также заданы прямо на SVG-путях: при тёплом
пересоздании Lovelace браузер не успевает показать стандартную чёрную
заливку до восстановления стилей компонента. Редакторы, киоск и быстрые
переключения вкладок работают по-прежнему без задержки.
- **Действия карточки устройства не выходят за границы узкого диалога.**
Кнопки «Редактировать», «Открыть в HA» и «Закрыть» теперь переносятся только
целиком, если в малом диалоге HA не хватает одной строки; на телефоне также
уменьшены боковые отступы. Первая кнопка больше не уезжает за левый край.
## v1.59.2 — 2026-08-07
+136
View File
@@ -0,0 +1,136 @@
# Background editor: current contract
Status: implemented in v1.60.0. This document is the source of
truth for the decorative layer. `BACKDROP.md`, `LIVE-TEXT.md` and
`FURNITURE.md` describe the specialised parts.
## Purpose and invariants
The Background editor is a visual annotation layer. Its shapes, furniture,
text and plan image never participate in rooms, wall geometry, light routing,
device state or Home Assistant actions.
| Invariant | Contract |
|---|---|
| Coordinates | Axis-aligned positions and every box dimension are quantised to the space grid. A rotated resize keeps its opposite world-space corner fixed and quantises dimensions; its derived unrotated storage origin is not post-snapped, because that would translate the entire object. `Shift` never disables dimension snapping. |
| Physical values | Stroke and text sizes are stored in centimetres; the UI shows cm/in for small values and m/ft for object dimensions. |
| Undo | Decor and backdrop use the same named 50-command stack as plan geometry. |
| Cancel | `Esc` restores the state at the start of an active drag/draw/resize/rotate gesture. A completed gesture is reverted with Undo. |
| Selection | One click selects; drag moves; the selected object has one common transform frame; double click opens all editable properties. |
| Scale | Corner drag preserves aspect ratio. Hold `Shift` for independent axes. |
| Rotation | 5° steps by default; `Shift` gives free rotation. Lines use endpoint handles instead of a rotation handle. |
| Magnet targets | Only other decor objects and room contours: corners, edge centres, centres and edges. The image, devices and openings are excluded. |
| Compatibility | Legacy `width`, text `size/scale` and `plan_scale` remain readable. New writes use `width_cm`, `size_cm` and `plan_scale_x/y`. |
## Tools
| Tool | Pointer action | Live feedback | Properties |
|---|---|---|---|
| Select | Select/move any decor object; corner handles resize; upper handle rotates | common selection frame and standard move/resize/rotate cursors | double click opens geometry, angle, contour/fill colour and opacity; text also exposes its content |
| Plan backdrop | Move the image by its body; resize/rotate by the frame | size badge; 5° rotation step | double click opens width, height and angle |
| Line | Drag between two grid points | length, angle, magnetic alignment guides | endpoint handles; length, angle, stroke colour/opacity/thickness |
| Rectangle | Drag a diagonal; `Shift` makes a square | width × height and area | size, angle, contour, optional independent fill colour/opacity |
| Oval | Drag its bounding box; `Shift` makes a circle | `R` for a circle, `Rx × Ry` for an oval | bounding size, angle, contour and optional fill |
| Text | Click to open the text form | the saved label is selected immediately | content, HA variables, colour/opacity, physical size and angle |
| Furniture | Pick a symbol, then click its centre | wall magnet unless `Shift` is held | size, angle and contour style; the common frame preserves ratio unless `Shift` is held |
| Erase | Click a decor object | confirmation dialog, then removal | Undo restores it |
The editor always opens on **Select**. If the space has an image, the Plan
backdrop tool appears next to Select but is never armed implicitly.
## Plan image behaviour
The plan image is interactive only while **Plan backdrop** is selected.
| Context | Image opacity | Frame/body interaction |
|---|---:|---|
| View, Plan editor, Device editor | 1.0 | none |
| Background editor, Select/drawing/furniture/erase | 0.5 | none; the stage remains available for pan/draw |
| Background editor, Plan backdrop | 1.0 | move, proportional/independent resize, rotate |
Stored transform fields:
```text
plan_x, plan_y top-left offset, normalised to the square canvas
plan_scale_x, plan_scale_y independent positive multipliers
plan_angle degrees, normalised to -180..180
```
`plan_scale` is the legacy uniform fallback. The maintenance command converts
it losslessly to both axis fields. Reset removes every transform field.
## Style and units
New decor writes use:
```text
color, opacity, width_cm
fill, fill_color, fill_opacity rectangles and ovals only
size_cm text only
```
`width_cm` and text `size_cm` are independent physical styles. Resizing a
rectangle or sofa does not make its outline thicker. A legacy object with render-unit `width` stays
pixel-identical until edited or explicitly optimised; conversion is
`width_cm = width / GRID_PITCH × cell_cm`.
The toolbar values are session defaults for newly drawn objects. Colour is a
compact swatch; its popover owns both the native colour field and opacity, so
alpha controls do not consume permanent toolbar space. Double-click properties
edit an existing object without creating a second style model.
## Interaction state machine
```text
idle → draft/move/scale/rotate → release → named history command → debounced save
↘ Esc → restore transaction start → idle
```
Only the active tool owns pointer events. Drawing tools can therefore start a
new line or figure exactly on top of an existing object. Select and Erase are
the general tools that target existing decor. The deliberate exception is
Text: clicking an existing text label with Text opens that label's editor;
clicking any non-text shape still starts a new label. The backdrop body belongs
only to the Plan backdrop tool.
`Ctrl/Cmd+Z` first cancels an unfinished draft or live gesture, then walks the
shared history. `Ctrl+Shift+Z` and `Ctrl+Y` obey the same transaction boundary:
the first invocation cancels a live gesture, the next redoes. Native text-field
history wins while focus is inside an input.
## Code ownership
| Concern | File |
|---|---|
| persisted decor types and future custom-image transform contract | `src/editors/decor/types.ts` |
| physical style conversion, oriented boxes, resize and snapping | `src/editors/decor/geometry.ts` |
| shared colour/opacity field | `src/hp-color-opacity.ts` |
| orchestration, dialogs and SVG transform frames | `src/houseplan-card.ts` |
| static/full-card backdrop rendering and content bounds | `src/space-render.ts`, `src/space-geometry.ts` |
| accepted persisted ranges | `custom_components/houseplan/validation.py` |
| explicit legacy conversion | `src/plan-optimizer.ts` |
The current root card still owns orchestration. Future extraction should move
it into `src/editors/decor/decor-editor.ts` without changing the typed model or
geometry helpers.
## Deferred custom images
User-provided decorative images are not exposed yet. The typed
`DecorImageTransform` contract already matches the common selection controller
(`x/y/w/h/angle/opacity`). A future image kind must additionally define safe
upload/reference lifecycle, quotas, copy-on-write and deletion semantics before
it is added to `DecorShape`; it must not reuse the plan backdrop file lifecycle
implicitly.
## Edge cases
- Degenerate drafts below half a cell are discarded and do not enter history.
- Unknown furniture symbols remain stored but render nothing in an older card.
- A rotated box contributes all four rotated corners to content bounds.
- A rotated backdrop is hit-tested in its own local coordinate system.
- Changing `cell_cm` changes the rendered width/font size of canonical physical
styles, as expected: it changes the scale of the whole space. Legacy
render-unit strokes/text retain their old pixels until migration.
- External config revisions clear the session history; undo never applies a
command to a different server revision.
+58 -9
View File
@@ -1,14 +1,15 @@
# Filtering: the explicit "hide from plan" flag
# Filtering: hide versus delete
Agreed with the owner 2026-07-29. This document is the source of truth for the
mechanism; the code follows it.
## Principle
Whether a device is on the plan is an EXPLICIT, per-device fact: the
bottom-left "Hide" / "Show" action, stored as `marker.hidden`. The old on-the-fly
filtering algorithm survives only as the SEEDER of those flags — it decides
the initial value once, and the user owns the flag from then on.
Whether a device is on the plan is an EXPLICIT, per-device fact. The
bottom-left actions have deliberately different meanings: Hide/Show is a
reversible presentation flag, while Delete removes the plan object and every
plan-level contribution. The old on-the-fly filtering algorithm survives only
as the SEEDER of initial hidden flags.
## Data model
@@ -17,6 +18,11 @@ the initial value once, and the user owns the flag from then on.
- `marker.hidden: false` (marker present) — explicitly VISIBLE: the seeder
never touches a device that has any marker, so unhiding must KEEP the stub
marker. That is the re-seed protection.
- `marker.removed: true` — a minimal binding tombstone. It is not a marker and
is never built, rendered, shown as a ghost or aggregated. It exists only so
automatic discovery cannot immediately recreate the deleted device. The
same binding remains available in Add; saving it again replaces the
tombstone and starts with a fresh position.
- No marker — never evaluated by the seeder yet, or a plain physical device.
- `settings.filter_seeded: true` — this config has been materialised.
- `settings.show_all` — removed (deleted during materialisation). The old
@@ -45,6 +51,12 @@ the old behaviour until an editing client materialises it.
## Behaviour
| State | Renders | Show hidden | Room/light/climate data | Add picker |
|---|---:|---:|---:|---:|
| visible marker/device | yes | — | yes | no duplicate |
| `hidden: true` | no | ghost | LQI/climate yes, visible light no | no duplicate |
| `removed: true` | no | no | no | yes |
- Hidden devices ARE built (flagged `hidden`), but not rendered in any mode,
except the device editor with "Show hidden devices" on — there they render
ghosted (translucent, dashed) and clicking opens the dialog, where the
@@ -66,8 +78,26 @@ the old behaviour until an editing client materialises it.
unaffected, as before.
- The bottom-left "Hide" / "Show" action appears in the dialog of every
existing device kind, virtual included; changing it is applied by "Save".
- "Remove from plan" disappears for auto/entity devices (the hide action is
the one way to hide); a virtual device's "Delete" remains a real deletion.
- "Delete" appears beside Hide/Show for every existing marker. It asks for
confirmation and commits immediately. HA device/entity markers leave only
the tombstone above; virtual markers are removed outright.
- Deleting a generated light-group binding also removes that group from the
plan's light-source set. On a legacy unseeded config its formerly folded
member lamps may consequently appear as ordinary devices. This is deliberate
delete semantics, not restoration of a hidden group.
- Deletion removes the marker layout, pending activity state, attachments and
saved vacuum trails. A late drag from a stale browser tab is ignored by the
server while the tombstone exists.
- A deleted **device binding** is excluded from room LQI, light-source
resolution, room light statistics/fill/Glow, registry-wide climate averages
and explicit room sources. An **entity binding** tombstone suppresses that
standalone plan object; it does not remove the same entity from the data of
a still-live parent HA device. Tombstones are binding-scoped, not mutations
of the HA registry.
- References in openings, live text and another marker's persisted `controls`
are retained but become inactive (no lock/contact badge, `—`, or control
action respectively). Adding the binding again restores them without
reconstructing configuration or performing an unrelated Save first.
- Duplicate names are still numbered, light groups still fold — those are
aggregation, not hiding.
@@ -81,8 +111,11 @@ source precedence is (`_visualSamples` / `_actEntity`):
«Открыть/закрыть» (`tap_action: 'cover'` — `coverEntityOf`, the same helper
and the same entity the tap drives). It wins over EVERYTHING below;
2. the marker's **resolved light sources** (`resolvedLightSources`): bound
`controls` first, otherwise its primary controllable entity when
`is_light` is set, otherwise automatic `light.*`. This exact set also feeds
`controls` first (other targets only: an `entity:*` marker's bound entity and
a `device:*` marker's child entities are excluded), otherwise its primary controllable entity when
`is_light` is set, otherwise automatic `light.*` only when `light` is the
device's resolved functional role. An auxiliary LED/display light on a
media player or appliance does not turn the whole marker into a lamp. This exact set also feeds
Glow, Light fill, room light stats and group toggle;
3. otherwise the device's **resolved state role**
(`resolvedDeviceStateEntities`): functional device domains first, then
@@ -126,6 +159,22 @@ rules 2–3 (the statement is only as strong as the entity behind it), and a
curtain left on «Инфо-карточка» still indicates its primary — one click in the
dialog away, and the honest reading of what the marker was told it is.
### A media player is powered, not "working" (owner 2026-08-07)
The resolved role stays `media_player.*` for every TV, receiver, speaker and
soundbar; no model/name exception is involved. Its HA transport states
(`on`, `idle`, `playing`, `paused`, `standby`, etc.) all produce a neutral
marker with no running effect. Explicit `off` deliberately reuses the existing
`.dev.unavail` faded presentation used by `unknown` / `unavailable`; it does
not add another visual status. When a marker resolves several media entities,
it fades only if none is currently available and powered.
The media role also outranks auxiliary `light.*`/`switch.*` entities belonging
to the same physical device. Status LEDs, display illumination and vendor
options therefore neither paint the media marker nor enter room light
aggregates automatically. Explicit marker `controls` or `is_light` remains the
user override for a real light source.
### A cover is never painted (owner 2026-08-04)
«У штор не должно быть жёлтой подложки НИКОГДА, индикация открыто/закрыто за
+14 -16
View File
@@ -9,7 +9,7 @@ branch). Tests: `test/furniture.test.mjs`,
`tests_backend/test_validation.py::test_decor_furniture`; smoke:
`demo/smoke_furniture.mjs`.
The shape: `{kind:'furniture', symbol, x, y, w, h, color, width, angle?}` —
The shape: `{kind:'furniture', symbol, x, y, w, h, color, opacity, width_cm, angle?}` —
a **new** decor kind, so no existing plan carries one, nothing is migrated,
and every plan written before this validates and renders byte-for-byte as
before.
@@ -189,23 +189,20 @@ confined to two places:
piece of furniture has a box, and turning a sofa about its top-left corner
is not what anyone means by turning a sofa. So: the anchor for text, the
**centre** for furniture (`_dtPivot`).
- **what a corner means.** For a label it is one font multiplier. For
furniture it is **width and depth, independently**, about the opposite
corner.
**Why independent and not proportional.** A picture has one true aspect ratio
and stretching it is a lie — which is why the backdrop scales uniformly. Furniture
is the opposite case: the ratio is a fact about *this* sofa, and the next one
is 1.8 m long with the same 0.9 m depth. A uniform handle would force the user
to make a bed deeper in order to make it wider, i.e. to state something false
about the room. So both axes move. Each dimension is quantised to a whole cell,
which on an on-grid piece also puts the dragged corner on a node. That is what
"snap to the grid" has to mean when both sides move.
- **what a corner means.** For a label it is one physical font size. For
furniture it is width and depth about the opposite corner. The common
controller preserves the current ratio by default; hold `Shift` for
independent axes. Each extent and the stored top-left coordinate remains
grid-bound.
**Rotation** is the handle above the box, in **5° steps**, Shift past the step
— the same step a device icon and a text block turn in. Turning back to zero
removes the field, so a straight piece stores no angle at all.
Double click opens the common properties dialog. It exposes the symbol itself,
physical width/depth, angle, contour colour/opacity and physical line width;
changing the symbol keeps the current box and transform.
**Live measurements.** While a corner is dragged, two badges show the piece's
real width and depth in the HA unit system, centred on the edges they measure.
They are `.measurelabel`, fed by the same `_fmtLen` (`segmentCm` over
@@ -230,15 +227,16 @@ one particular bed, without us shipping a CSS field:
.decorlayer [data-kind="furniture"][data-symbol="bathtub"] { stroke: #4fc3f7; }
```
The colour and the line width are the decor style's, chosen in the bar like
any other shape's, and are stored per piece.
The colour, opacity and physical line width are the decor style's, chosen in
the bar like any other shape's, and are stored per piece.
## 8. Backend
`DECOR_SCHEMA` gains a fourth branch: `symbol` required, `^[a-z0-9_]+$`,
≤ 32 characters; `x`/`y` the usual ±`CANVAS_LIMIT`; `w`/`h` strictly positive
and capped by the same limit (a size, not a coordinate); `angle` optional,
−360…360. `color`/`width` come from `_DECOR_COMMON` unchanged.
−360…360. `color`/`opacity`/`width_cm` come from `_DECOR_COMMON`; legacy
render-unit `width` remains accepted until the object is edited or optimised.
The symbol is **not** validated against the card's list. The backend must
accept a plan written by a NEWER card, and a card that has learnt a new symbol
+21 -15
View File
@@ -9,8 +9,8 @@ Status: **released in v1.59.0-rc.1.** Code: `src/logic.ts`
Smokes: `demo/smoke_live_text.mjs`, `demo/smoke_decor_text.mjs`;
`demo/smoke_decor.mjs` keeps the surrounding decor contract.
The shape: `{kind:'text', x, y, text, color, scale?, angle?}` — plus legacy
`size?`, `entity?`, `attr?`, and `unit?` fields that older plans may still
The shape: `{kind:'text', x, y, text, color, opacity?, size_cm?, angle?}` — plus legacy
`size?`, `scale?`, `entity?`, `attr?`, and `unit?` fields that older plans may still
carry. New live references are stored only inside `text`; existing linked
labels render unchanged and migrate to inline references when that conversion
is lossless. A legacy explicit `unit`, or an attribute name that cannot be
@@ -56,11 +56,13 @@ characters.
- The value is read live from `hass` on every render — the same source as the
rest of the card, no polling and no subscriptions of its own. A new `hass`
repaints the label; nothing is re-created.
- **Unavailable / unknown / missing entity** → the value renders as `—` (an
- **Unavailable / unknown / missing or deleted-from-plan entity** → the value renders as `—` (an
em dash) **and the dash carries no unit** («— °C» is not a reading); the
rest of the template stays. A label that silently disappears when a sensor
dies is worse than one that says "no data": the user must see that the
caption is alive and the sensor is not.
- Deletion does not rewrite the label template. Re-adding the same HA binding
makes the saved variable live again.
- An attribute that is not on the entity, or that is a dict, renders as the
same dash. A list attribute is joined with `, `; `0` and `false` are values,
not absences.
@@ -95,18 +97,21 @@ characters.
The old `size: 's'|'m'|'l'` selector is **gone from the dialog**. A caption's
size is not one of three opinions; it is whatever fits the place it is put in.
- **`scale`** — a font multiplier against the base 20 px, written by dragging
a corner of the selected block (select tool). Bounded `0.15…20` in the card
and in the backend.
- **`size_cm`** — the canonical physical font size, shown as centimetres or
inches and written both by the numeric properties field and by dragging a
corner of the selected block. Text always scales proportionally, including
with `Shift`; changing the plan scale therefore keeps the label's physical
size meaningful. The backend bounds it to `0.1…2000 cm`.
- **`angle`** — degrees, written by the handle above the block. The step is
**5°**, the same step a device icon rotates in; **Shift** drags past it, as
past every other snap in this card (docs/CANVAS.md §9.4). Rotating back to
**5°**, the same step a device icon rotates in; **Shift** enables a free
angle but never disables positional grid snapping. Rotating back to
zero removes the field, so a straight label stores nothing.
- **No migration for `size`.** A stored `size` is read as the multiplier it
- **Legacy size is read without a silent migration.** A stored `size` is read as the multiplier it
used to render at — `s` = 0.7 (14 px), `m` = 1 (20 px), `l` = 1.5 (30 px) —
so an old label comes back at exactly its old size. An explicit `scale`
wins, and the first corner drag **replaces** `size` with the scale it meant:
a shape never states its size twice. `size` stays in `DECOR_SCHEMA` (still
so an old label comes back at exactly its old size. An explicit legacy
`scale` wins. The first corner drag or properties save replaces both with
the equivalent `size_cm`; **Оптимизировать планы** performs the same lossless
conversion explicitly for the whole model. `size` stays in `DECOR_SCHEMA` (still
bounded to the three known values) precisely because old plans keep sending
it.
- **Line breaks are the user's own.** The dialog's field is a textarea; a
@@ -166,9 +171,10 @@ click opens its editor, and the corner/rotate handles appear.
## 6. Backend
`DECOR_SCHEMA`, text branch: `text` ≤ 200 characters, newlines and inline
references included; `scale` optional, finite, `0.15…20`; `angle` optional,
finite, `-360…360`. Legacy `entity`/`attr`/`unit` remain accepted and bounded
`DECOR_SCHEMA`, text branch: `text` ≤ 200 characters, `opacity` 0…1, newlines and inline
references included; canonical `size_cm` is finite `0.1…2000`; `angle` is
optional and finite `-360…360`. Legacy `size`, `scale` (`0.15…20`) and
`entity`/`attr`/`unit` remain accepted and bounded
so old saved plans continue to validate and render. The frontend writes none
of them after an ordinary representable label has been edited. It deliberately
retains them when dropping an explicit unit or non-representable attribute would
+12 -6
View File
@@ -1,6 +1,6 @@
# House Plan — продуктовый аудит и план улучшений
Срез: **v1.59.0-rc.1**, 6 августа 2026. Документ основан на чтении исходников, локальном проходе главных пользовательских сценариев и проверке фактических состояний интерфейса. Для принятых задач в нём также фиксируется актуальный статус реализации.
Срез: **v1.60.0**, 7 августа 2026. Документ основан на чтении исходников, локальном проходе главных пользовательских сценариев и проверке фактических состояний интерфейса. Для принятых задач в нём также фиксируется актуальный статус реализации.
## 1. Краткая оценка
@@ -49,12 +49,17 @@ House Plan уже вышел за рамки «карточки с картин
| UX-01 | P0 | Незамкнутый контур существует только в памяти текущего инструмента и исчезает при переключении | Потеря длинной ручной работы без явного предупреждения | Сохранять черновик пространства на сервере или показывать диалог «Сохранить черновик / отбросить / остаться»; в перспективе реализовать уже спроектированные свободные стены |
| UX-02 | P1 | **Выполнено 2026-08-06:** контекстный Delete разделён на «Закрыть проём в границе», «Объединить комнаты» и «Удалить комнату» | Каждое действие имеет один заранее понятный результат | Delete удаляет только явно выбранную комнату; слияние и закрытие границы не запускаются из него |
| UX-03 | P1 | **Выполнено 2026-08-06:** инструмент замкнутой комнаты называется «Контур комнаты» | Название соответствует доступной геометрии | После реализации свободных стен добавить отдельный инструмент «Стена», не возвращая неоднозначное название |
| UX-04 | P1 | **Выполнено 2026-08-06:** все операции Plan editor используют общий именованный Undo/Redo | Merge, Split, Resize, толщина, виртуальные границы и дверные/оконные проёмы отменяются одинаково | Сессионный command stack хранит 50 команд; новая операция очищает redo-ветку; Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y и кнопки работают во всех инструментах Plan |
| UX-04 | P1 | **Расширено 2026-08-07:** Plan и Background используют общий именованный Undo/Redo | Комнаты, проёмы, декор и трансформация картинки отменяются одинаково | Сессионный command stack хранит 50 команд; новая операция очищает redo-ветку; Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y и кнопки работают в обеих панелях |
| UX-05 | P1 | **Решение владельца 2026-08-06:** координаты объектов всегда привязаны к сетке; намеренный off-grid не поддерживается | Оптимизация и редакторы больше не противоречат друг другу | Позиционный обход через `Shift` удалён. Свободные точки округляются к узлам, wall-bound объекты проецируются на стену и квантуются вдоль неё; «Оптимизировать планы» исправляет старые и импортированные данные |
| UX-06 | P2 | После UX-05 `Shift` больше не влияет на координаты, но остаётся модификатором угловой точности и шага компаса | Позиционный инвариант не нарушается; угловые исключения требуют явной подсказки | Документировать `Shift` только рядом с угловыми контролами; не использовать его для обхода сетки |
| UX-06 | P2 | После UX-05 `Shift` не отключает сетку, но меняет геометрию жеста: квадрат/круг при рисовании, независимые оси при resize, свободный угол и шаг компаса | Позиционный инвариант не нарушается; контекстные исключения требуют явной подсказки | Показывать подсказку рядом с соответствующим инструментом; не использовать `Shift` для off-grid |
| UX-07 | P2 | Ручки комнатной карточки, локальные масштабы шрифтов и общий масштаб пространства образуют несколько перемножающихся уровней | Трудно понять, почему подпись слишком большая/маленькая | Оставить: общий масштаб пространства + размер названия/метрик комнаты; визуальный drag-scale либо заменить теми же числовыми полями, либо показывать итоговый коэффициент и Reset |
| UX-08 | P2 | Проём кликабелен в редакторе, но полностью инертен в просмотре; только badge замка активен | Неочевидно, где увидеть контакт и параметры двери/окна, особенно без замка | Одиночный клик в View открывает карточку статуса проёма; редактирование остаётся только в Plan |
| UX-09 | P2 | **Выполнено вместе с UX-02:** открытие участка и закрытие пунктирного участка — разные инструменты | У кнопки открытия больше нет обратного скрытого действия | Сохранять разные подписи, курсоры и hover-подсказки |
| UX-26 | P1 · выполнено | Background имел разные рамки/жесты для текста, мебели и картинки; линии/фигуры нельзя было трансформировать одинаково | Выбор объекта давал разный набор возможностей | Единый selection/transform controller: все объекты получают рамку; линия — две конечные ручки; пропорции по умолчанию, `Shift` — независимые оси |
| UX-27 | P1 · выполнено | Толщина, размер текста и заливка декора были в SVG-единицах/коэффициентах, прозрачность отсутствовала, заливка использовала цвет контура и фиксированные 25% | Масштаб пространства менял физический смысл стиля | Канонические `width_cm`, `size_cm`, `opacity`, `fill_color`, `fill_opacity`; cm/in для малых стилей, m/ft для геометрии; legacy читается без скачка и мигрируется оптимизатором |
| UX-28 | P1 · выполнено | Картинка была активна в Select и непрозрачна под всеми инструментами | Рамка перехватывала работу с декором, изображение визуально спорило с ним | Отдельный инструмент картинки; под другими инструментами opacity 0.5 и нет interaction; View/прочие редакторы 1.0; resize/rotate/numeric properties и Undo |
| UX-29 | P2 | Типы, физическая геометрия и `hp-color-opacity` вынесены, но orchestration Background пока остаётся в `houseplan-card.ts` | Корневой компонент всё ещё слишком велик и связан с pointer/render жизненным циклом | Следующий рефакторинг без изменения поведения: `decor-editor.ts`, `live-text-form.ts`, `furniture-palette.ts`; root оставляет только wiring |
| UX-30 | P2 · отложено | Пользовательские картинки декора требуют не только нового SVG-kind, но и безопасного файлового жизненного цикла | Наивное добавление создаст сироты, конфликт удаления и квот | Сначала спроектировать upload/reference/copy-on-write/delete/quota; `DecorImageTransform` уже фиксирует совместимый transform-контракт, UI не реализован |
### 3.2 Устройства и состояния
@@ -107,7 +112,8 @@ House Plan уже вышел за рамки «карточки с картин
| `settings.show_all` | Legacy shared-toggle; текущая версия удаляет его после materialization | Завершить миграцию и убрать write/fallback после установленного окна совместимости |
| `settings.exclude_integrations` | Внутренний ключ влияет на фильтр, но публичного UI/документации нет | Либо сделать поддерживаемой advanced-настройкой с причинами, либо заменить версионированными filter rules |
| `settings.group_lights` | Влияет на группировку, но не виден пользователю | Добавить явную настройку в Device discovery либо зафиксировать always-on и удалить ключ |
| Старые `decor.text.entity/attr/unit/size` | Схема принимает для миграции, новый UI их не создаёт | Оставить только мигратор; после гарантированного преобразования вынести из основной модели совместимости |
| Старые `decor.text.entity/attr/unit/size/scale` | Схема принимает для миграции, новый UI их не создаёт | Оставить только мигратор; после гарантированного преобразования вынести из основной модели совместимости |
| Старые `decor.width` и `space.plan_scale` | Читаются без визуального изменения; новый UI пишет `width_cm` и `plan_scale_x/y` | Мигрировать только явной операцией «Оптимизировать планы»; не менять молча при загрузке |
| Старый `space.segments` | Backend уже удаляет ключ, стены выводятся из комнат | Удалить остаточные ссылки из публичной архитектурной документации и тестовых фикстур после окна совместимости |
| Старые README-сценарии | Описывают карточечный tap и исторические display-режимы | Новый user guide сделать источником истины; README сократить до презентации, установки и ссылки на руководство |
@@ -165,7 +171,7 @@ House Plan уже вышел за рамки «карточки с картин
| Узкое место | Доказательство | Риск |
|---|---|---|
| Монолит карточки | `src/houseplan-card.ts`: 10 932 строки | Любая правка задевает жизненный цикл, диалоги, геометрию и render одновременно |
| Монолит карточки | `src/houseplan-card.ts`: более 11 тысяч строк | Любая правка задевает жизненный цикл, диалоги, геометрию и render одновременно |
| Монолит стилей | `src/styles.ts`: 2 457 строк | Трудно ограничить CSS конкретным компонентом и состоянием |
| Слабая типизация | Около 429 `any` в TS | Schema/UI/backend расходятся незаметно |
| Состояние как набор mutable fields | Сотни приватных полей диалогов и drag-сессий | Трудные переходы, warm remount, отмена и параллельные изменения |
@@ -215,7 +221,7 @@ src/
hp-status-preview.ts
```
`logic.ts`, `device-visual.ts`, `sun.ts`, `vacuum.ts`, `open-spans.ts` и `wall-thickness.ts` уже показывают правильное направление: чистая доменная логика отдельно от Lit/DOM. Следующий этап — вынести orchestration и формы.
`logic.ts`, `device-visual.ts`, `sun.ts`, `vacuum.ts`, `open-spans.ts` и `wall-thickness.ts` уже показывают правильное направление: чистая доменная логика отдельно от Lit/DOM. Для Background первый этап выполнен: `src/editors/decor/types.ts`, `geometry.ts` и `hp-color-opacity.ts` отделяют модель, вычисления и общий контрол. Следующий этап — вынести orchestration и формы, не создавая второй набор жестов.
### 6.3 Command model
+2 -1
View File
@@ -1,10 +1,11 @@
# Документация House Plan
Этот каталог — новая точка входа в русскоязычную документацию House Plan. Материалы ниже сверены с исходным кодом и интерфейсом версии **v1.59.2**.
Этот каталог — новая точка входа в русскоязычную документацию House Plan. Материалы ниже сверены с исходным кодом и интерфейсом версии **v1.60.0**.
| Документ | Для кого | Что внутри |
|---|---|---|
| [Полное руководство пользователя](USER-GUIDE.ru.md) | Пользователи и администраторы Home Assistant | Установка, первая настройка, пространства, комнаты, стены, проёмы, устройства, визуальные состояния, заливки, подложка, солнце, пылесосы, киоск, обслуживание и диагностика |
| [Редактор подложки и декора](DECOR-EDITOR.md) | Пользователи, тестировщики и разработчики | Инструменты, единое выделение и трансформации, физические размеры, магнит, Undo/Redo, поведение картинки-подложки и совместимость старых полей |
| [Продуктовый аудит и план улучшений](PRODUCT-IMPROVEMENT-PLAN.ru.md) | Владелец и разработчики продукта | Нелогичные, дублирующие и устаревшие функции, UX-риски, технический долг и поэтапный план рефакторинга |
Старые тематические документы в этом каталоге остаются инженерными спецификациями и историей решений. При расхождении пользовательского описания с интерфейсом текущей версии приоритет имеет новое руководство, а при расхождении с фактическим поведением — код текущей версии.
+21 -2
View File
@@ -15,11 +15,11 @@
| Item | State |
|---|---|
| Version | **v1.59.2** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable accessibility maintenance release. Tag `v1.59.2`, `main` and `dev` point to the same tested release commit; GitHub Release uses `prerelease=false`. Previous pre-release: v1.59.0-rc.2; previous stable: v1.59.1 |
| Version | **v1.60.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable release of the unified Background editor, true device deletion and the complete beta audit hardening. Stable publication fast-forwards `main` to the exact tested `dev` commit and uses `prerelease=false` |
| Workflow | Owner's rule since 2026-08-05: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets the full tests/build/smokes, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. A requested stable release gets the same full gate, then `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, 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 | v1.59.2 passes the full local release gate; exact counts are recorded by `npm run inventory` and the test logs rather than copied here. Native Windows cannot start the current HA pytest plugin (`fcntl` is Unix-only), so the exact-SHA Ubuntu Validate remains mandatory: `release.yml` withholds the asset until every matching run finishes green; GitHub Validate and the release asset are verified after the tag is pushed |
| CI | v1.60.0 uses the full local release gate; exact counts are recorded by `npm run inventory` and the test logs rather than copied here. Native Windows cannot start the current HA pytest plugin (`fcntl` is Unix-only), so the exact-SHA Ubuntu Validate remains mandatory: `release.yml` withholds the asset until every matching run finishes green; GitHub Validate and the release asset are verified after the tag is pushed |
| 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), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
@@ -67,6 +67,11 @@
bottom-left "Hide" / "Show" action in the device dialog; local
"Show hidden" ghosts; hidden counts toward room LQI on both cards, casts
no light (v1.51.1).
- **True plan deletion** (v1.60.0-beta.1, 2026-08-07): confirmed Delete beside Hide/Show;
a minimal `marker.removed` tombstone prevents auto-rediscovery but exposes
the binding to Add. Deleted devices are absent from every plan aggregate and
linked presentation; layout/files/trails are cleaned and stale layout writes
are rejected.
- **Yellow = working right now** (v1.51.0): climate by hvac_action, service
switches can no longer become primary, glow pool and icon share one
condition. Editor gestures on touch (pinch/pan) landed the same release.
@@ -75,6 +80,20 @@
actual work, orange open/unlocked, unavailable and always-red alarms;
activity distinguishes a short event, presence, mechanical travel and
running. Legacy Ripple-only migrates to Icon + activity on the next save.
- **Passive media lifecycle** (v1.60.0-beta.1): every `media_player`,
regardless of model, stays neutral while powered or playing and fades to
the existing unavailable appearance on explicit `off`; transport playback
is no longer classified as yellow actual work and no new visual state is
introduced.
- **Unified Background editor** (v1.60.0-beta.1): typed decor model,
physical cm/in strokes and text size, independent contour/fill opacity, decor+room smart
magnet, common selection/resize/rotate frame for every decor kind, numeric
geometry properties, and shared 50-command Undo/Redo. The plan image now has
an exclusive tool, 0.5 editor de-emphasis elsewhere, independent axes,
rotation, numeric properties and rotated content bounds. Legacy `width` and
`plan_scale` remain read-compatible and migrate only through explicit plan
optimisation. Furniture properties also expose the symbol itself. See
`DECOR-EDITOR.md`.
- **v1.59.0-rc.1** (2026-08-06): whole-plan lossless optimization with an
atomic config+layout commit and safe undo; the yellow actual-work plate is
retained alongside source glow; all Background objects have Select-mode
+1 -1
View File
@@ -96,7 +96,7 @@ Everything in this table is **public API**.
| Room card (name + metrics) | `div` (HTML, marker layer) | `room-label` | `data-id` = room id, `data-area` | `.roomlabel` |
| Door / window | `g` (SVG) | `opening` | `data-id` = opening id, `data-kind` = `door` \| `window` | `.opening` |
| Wall body (thickness) | `path` (SVG) | `wall` | `data-id` = segment key, `data-kind` = `shared` \| `outer` | `.wallbody` |
| Decor shape | `line` / `rect` / `ellipse` / `text` (SVG) | `decor` | `data-id` = shape id, `data-kind` = `line` \| `rect` \| `ellipse` \| `text` | `.dshape` (`.dtext` on text) |
| Decor shape | `line` / `rect` / `ellipse` / `text` (SVG) | `decor` | `data-id` = shape id, `data-kind` = `line` \| `rect` \| `ellipse` \| `text` | `.dshape` (`.dtext` on text); persisted colour/alpha are inline SVG attributes and therefore win over weak CSS selectors |
| Furniture | `path` (SVG) | `decor` | `data-id` = shape id, `data-kind` = `furniture`, `data-symbol` = the symbol id (`sofa`, `toilet`, …) | `.dshape .dfurn` |
| Floor / space tab | `button` (HTML, header) | `space-tab` | `data-id` = space id | `.tab` |
+128 -46
View File
@@ -78,8 +78,9 @@
rects, polygon vertices, view_box and openings — not only in layout; the
openings list honours MAX_OPENINGS [auto: tests_backend]
- [ ] Drag hardening (v1.44.4, L4 sub-item): every drag pipeline captures the
pointer through the tolerant helper; decor shapes cannot be dragged more
than a quarter of the plan outside the viewBox [auto: smoke_decor]
pointer through the tolerant helper; decor follows the infinite canvas
and stops only at the shared normalized ±5000 garbage bound
[auto: smoke_decor, smoke_drag_bounds]
- [ ] Room climate counts hidden sensors (v1.44.5): a thermometer that is NOT
placed on the plan (hidden by filtering or by the user) still feeds the
@@ -696,6 +697,15 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
since v1.52.0, target colours reach only the glow/activity effect; without explicit Toggle
the click opens info as usual; the info card lists targets with states;
locks/other domains are filtered out of controls [auto: smoke_controls]
- [ ] Bound-entity self-control regression: a marker bound to
`entity:switch.hood` with legacy `controls: ["switch.hood"]` is not a
light source/group, opens with no self chip and its explicit Toggle acts
directly on the switch. When ON it may still show the ordinary yellow
working-state plate, but creates no Glow/Light fill/statistics; setting
`is_light` explicitly restores those light behaviours
[auto: smoke_controls; unit: devices.test.mjs, plan-optimizer.test.mjs].
Repeat with a `device:*` binding whose controls contain one of that
device's child switches; the child is excluded while external targets remain
- [ ] Glow fill (v1.35.0): fill mode "Light sources" — every room painted with
one uniform darkness color; lit lamps glow with a radial gradient
(rgb_color → color temp → default color; brightness scales opacity),
@@ -713,24 +723,34 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
or auto position migrates to the new marker id; only a brand-new icon or
a move to another space centers it in the target room [auto: smoke_marker_stay]
- [ ] Icon picker placeholder (v1.33.3): with no explicit icon the device
dialog's icon picker shows the auto-derived icon as its placeholder, plus
an "Auto: mdi:..." hint line with the icon preview; the hint disappears
once an explicit icon is picked [auto: smoke_icon_placeholder]
dialog's icon picker shows both the glyph and `mdi:*` label of the
auto-derived effective icon, plus an "Auto: mdi:..." hint line with the
icon preview; opening/saving untouched must keep the explicit override
empty, and the hint disappears once an explicit icon is picked
[auto: smoke_icon_placeholder]
- [ ] No Reset button (v1.33.2): the Device editor toolbar has three tools —
add, show all, icon rules; the layout-wiping Reset is gone [auto: smoke_editor_tabs]
- [ ] Grid in all editors + decor fade (v1.33.1): the dot grid shows in the
Device and Background editors too (instant "I'm editing" cue), not in
View; in the Background editor rooms/devices/openings/labels fade to 35%
while decor shapes stay fully opaque; no fade in the other editors [auto: smoke_decor / smoke_grid_fade]
- [ ] Background editor (v1.33.0): third tab with its own toolbar (select /
line / rect / oval / text / erase + color, width, fill, X); shapes drag-
drawn with grid snap and live preview; degenerate shapes dropped. In
Select, double click always opens properties: text/HA variables for a
label, colour + line width for every other object, and Fill for rectangles
or ovals. Select moves (snap), Delete removes, Erase deletes on click;
Esc: draft → selection → select tool →
View; decor renders under rooms, visible everywhere, inert outside the
editor; stored in space.decor (rev/lock, backend schema) [auto: smoke_decor / smoke_grid_fade]
- [ ] Background editor (unified after v1.59.2): it always opens on Select and
has Select / optional Plan backdrop / Line / Rectangle / Oval / Text /
Furniture / Erase, colour+opacity, physical line width, optional
fill colour+opacity and named Undo/Redo. Shapes are drag-drawn with
mandatory grid snap and live preview; degenerate shapes are dropped.
One click selects every decor kind; lines get endpoint handles and box
kinds get the common proportional resize/rotate frame. Double click
opens complete numeric/style/content properties. Delete removes the
selection and Erase deletes on click. Esc restores only an active draft
or gesture; a released operation is reverted through Undo. Decor stays
purely visual and inert outside its editor [auto: smoke_decor /
smoke_grid_fade; unit: decor-geometry.test.mjs]
- [ ] Background-editor gesture regressions: a perfectly horizontal/vertical
rect or ellipse draft is discarded; switching tools during a live move
restores the pointer-down snapshot; resizing a rotated box keeps the
opposite corner fixed without a post-resize grid wobble
[auto: smoke_decor; unit: decor-geometry.test.mjs]
- [ ] Opening hover preview (v1.32.1): with the Opening tool, hovering near a
wall shows a dashed 90 cm ghost snapped onto the wall (with a center
dot); no ghost far from walls, over an existing opening (click = edit),
@@ -758,6 +778,10 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
info, device info, icon rules, general settings, device editor, opening
editor, space dialog incl. abandoning an import queue); stacked dialogs
close one per press; Esc while drawing still undoes the last point [manual]
- [ ] Shared dialog footer (dev after v1.59.2): in the wide device editor the
divider spans the full modal width, Hide is bottom-left and Cancel/Save
are bottom-right; the scrollable body ends above the footer. At narrow
width the two action groups wrap without overlap [manual]
- [ ] General settings gear (v1.30.3): the header cog is visible in every mode
(admins), opens the palette dialog from View too [auto: smoke_gear_tabs / smoke_gs_always]
- [ ] Editor tabs (v1.30.2): only two tabs — "Plan editor" / "Device editor"
@@ -789,7 +813,36 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Tap-action override select (default/info/more-info/toggle) saves and applies
- [ ] PDF/manual upload: ok path; >50 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
- [ ] `javascript:` in the link field is not rendered as a clickable link [manual]
- [ ] Remove: auto device → hidden marker (reappears via dialog "show all"? no — stays hidden until re-added); virtual → gone incl. its layout entry [auto backend]
- [ ] Delete versus Hide: both buttons sit together at bottom-left; Delete asks
for confirmation, Cancel changes nothing, Confirm closes the dialog
immediately [manual]
- [ ] Delete an auto device: no icon and no Show-hidden ghost; it does not
return on rebuild/reload, but its binding is offered by Add. Re-add it:
one marker only, fresh centred/grid position, no tombstone
[auto: smoke_hidden_flag; unit + manual]
- [ ] Delete an entity marker and a virtual marker: the entity is offered by
Add (with Show entities when applicable); the virtual marker is gone and
can be recreated manually. The exact deleted entity remains offered even
if HA marks its registry entry hidden. Other virtual markers survive both
Save and Delete [auto: smoke_hidden_flag; unit + manual]
- [ ] Deleted device contributes to none of LQI, climate average, explicit room
temp/humidity, resolved lights, Light fill, Glow, room stats or another
marker's controls. Hidden device keeps the documented hidden semantics
[unit + manual]
- [ ] References are non-destructive: a deleted contact/lock stops driving an
opening and a deleted live-text variable prints `—`; re-adding the
binding restores both. Layout, attachments and current/previous vacuum
trails are gone [unit + backend + manual]
- [ ] Tombstones stay binding-scoped: deleting a standalone entity does not
remove the same entity from a still-live parent device; temporarily
inactive `controls` remain stored and become active again after re-add.
A stale old card sees a non-virtual tombstone as hidden, never visible
[unit: devices.test.mjs; manual for the old-card fallback]
- [ ] Multi-tab race: after deletion, a stale tab's late layout/update is
acknowledged as ignored and cannot resurrect the old position [backend]
- [ ] A live explicit non-virtual marker whose custom id begins with `v_`
accepts both point and batch layout writes; only an actually orphaned
virtual id is ignored [backend: test_ha_websocket.py]
## Icon rules ★
@@ -901,9 +954,13 @@ require hands on real hardware — they remain for the human pass.
direct terminal `closed ↔ open` / `locked ↔ unlocked` without an
intermediate state breathes for about 3.3 s
- [ ] Actual work (light/switch/fan/humidifier on, active climate action,
media playing, vacuum cleaning, script running) is yellow and slowly
vacuum cleaning, script running) is yellow and slowly
breathes in Icon + activity; `automation = on` is merely enabled and
remains neutral
- [ ] Every `media_player` is neutral and has no running activity for `on`,
`idle`, `playing`, `paused` and other transport states; explicit `off`
uses the same faded treatment as `unknown`/`unavailable`. Several
resolved media entities fade only when none is available and powered
- [ ] Controls aggregate their targets: any working target drives both the
yellow plate and the running effect
- [ ] Open contact/unlocked lock/open valve are orange; an open cover stays
@@ -951,6 +1008,10 @@ require hands on real hardware — they remain for the human pass.
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.
- Calling trail delete for a marker with no stored run is a true no-op: its
source/vacuum pair stays subscribed. A successful delete removes only that
marker's pairs and immediately rebuilds the subscription
[backend: test_trail_recorder.py].
- 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.
@@ -1165,8 +1226,9 @@ require hands on real hardware — they remain for the human pass.
in the HA unit system (`cell_cm`, metres or feet) and turns green on a
45° multiple — exactly the badge a wall gets in the Plan editor. It
updates on every move, is absent before the drag has any length, and is
gone the moment the shape is committed. Rectangles and ovals show their
bounding box «W × H» instead [auto: smoke_decor]
gone the moment the shape is committed. Rectangles show «W × H» plus
area; circles show `R`, and non-circular ovals show `Rx × Ry`
[auto: smoke_decor]
## The text block on the plan (docs/LIVE-TEXT.md, dev, unreleased)
@@ -1198,14 +1260,16 @@ require hands on real hardware — they remain for the human pass.
[auto: smoke_live_text]
- [ ] **The same everywhere**: the label reads identically in View, in the
editors and on a kiosk screen [auto: smoke_live_text]
- [ ] **No font-size choice any more**: the dialog has no Small/Medium/Large.
Select a label and pull a corner — the text scales uniformly about its
anchor; the handle above it turns the block in 5° steps, Shift for any
angle. Rotating back to zero leaves a straight label
- [ ] **Physical text size**: the dialog has no Small/Medium/Large; instead it
exposes a numeric centimetre/inch size. Select a label and pull a corner
— the text scales uniformly about its anchor even with Shift; the handle
above it turns the block in 5° steps, Shift for any angle. A new/edited
label stores `size_cm`, while legacy `size/scale` remains pixel-identical
until edited or explicitly optimized. Rotating back to zero leaves a straight label
[auto: smoke_decor_text]
- [ ] **Old labels keep their size**: a plan made before the handles renders
its Small/Medium/Large labels at exactly the old 14/20/30 px, and the
first corner drag converts that setting into the scale it meant
first corner drag converts that setting into the equivalent `size_cm`
[auto: smoke_decor_text + unit logic.test]
- [ ] **Enter is a new line**: type two lines in the dialog (Ctrl/⌘+Enter or
the button saves) — the plan shows two lines, centred, growing around the
@@ -1253,24 +1317,26 @@ require hands on real hardware — they remain for the human pass.
snap to nodes in ONE write, openings stay on their walls, and pressing the
button a second time reports nothing to do. Cancel does nothing at all
[auto: smoke_grid_snap + unit test/align-grid.test.mjs]
- [ ] Optimizer migration safety: legacy decor width/text size is clamped to
the backend schema, `fill: true` receives explicit fill style, invalid
legacy `plan_scale` is preserved for repair, an already canonical plan is
a no-op, and a future model version is never downgraded
[unit: plan-optimizer.test.mjs; backend: test_validation.py]
## Backdrop picture: move & scale (docs/BACKDROP.md, dev)
- [ ] **The frame is there, and only there** (owner 2026-08-04): open a space
that HAS an uploaded plan image → **Редактор подложки**. A dashed frame
hugs the picture straight away, with four round corner handles, and the
toolbar has gained a «Картинка-подложка» tool. Switch to Line /
Rectangle / Oval / Text / Erase — the frame disappears (those tools own
the drag). Leave for View, the Plan editor, the Device editor and the
kiosk — no frame anywhere. A space with NO image never shows one, and
its toolbar has no extra button [auto: smoke_backdrop, smoke_decor]
- [ ] **The picture tool is already armed** (owner 2026-08-05, «не получается
двигать картинку-подложку»): open **Редактор подложки** on a space that
HAS a picture — «Картинка-подложка» is the selected tool, the hint about
dragging is in the toolbar, and the picture moves on the FIRST drag,
without picking anything. A space with no picture opens on «Выбрать» as
before, and Esc still returns to «Выбрать» [auto: smoke_backdrop,
smoke_hide_layers]
that HAS an uploaded plan image → **Редактор подложки**. It opens on
Select; the toolbar contains «Картинка-подложка». Select that tool: a
dashed, rotated frame hugs the picture with four corner handles and one
upper rotate handle. Switch to Select / Line / Rectangle / Oval / Text /
Furniture / Erase — the frame disappears and the image is pointer-inert.
Leave for View, Plan, Devices or kiosk — no frame anywhere. A space with
NO image has no image tool [auto: smoke_backdrop, smoke_decor]
- [ ] **Opacity is contextual**: under Select or any drawing/furniture/erase
tool the image opacity is exactly 0.5; under Plan backdrop it is 1.0.
View, Plan, Devices, kiosk and the static card always use 1.0
[auto: smoke_backdrop, smoke_hide_layers]
- [ ] **The corner handles are beads, not blobs** (owner 2026-08-05,
«уменьшить в 4 раза… они постоянно гигантские»): the four dots on the
picture's frame are small — they must not cover the picture — yet a
@@ -1293,7 +1359,15 @@ require hands on real hardware — they remain for the human pass.
and shrinks in BOTH directions at once, keeps its proportions (nothing
is stretched), and the OPPOSITE corner does not move a pixel. Try all
four corners; the cursor over a handle is a diagonal resize arrow. On a
tablet the handles are big enough to hit with a finger [auto: smoke_backdrop]
tablet the handles are big enough to hit with a finger. Repeat with
Shift: width and height now change independently, but stay grid-bound
[auto: smoke_backdrop]
- [ ] **Rotation and numeric properties**: the upper handle rotates around the
image centre in 5° steps; Shift allows any angle. Double click the image
while its tool is active, enter width/height in m/ft and an angle, save,
reload and compare. Esc during a live transform restores pointer-down;
after release Ctrl+Z restores and Ctrl+Y reapplies it [manual + unit:
backdrop.test.mjs]
- [ ] **Live size in metres**: while dragging or scaling, a badge in the middle
of the picture states its real size, «Ш × В», in the same units the wall
ruler uses (metres, or feet on an imperial HA). Change the space's
@@ -1304,8 +1378,9 @@ require hands on real hardware — they remain for the human pass.
two). After a corner scale one side of the picture ends on a node too;
holding Shift produces the same snapped result [auto: smoke_backdrop]
- [ ] **«Вернуть картинку»**: the button appears in the backdrop toolbar only
after the picture has been moved or scaled. Press it — the picture goes
back to centred, at its own size, and the button disappears again
after the picture has been moved, scaled or rotated. Press it — the picture goes
back to centred, at its own size and zero angle; Undo restores the prior
transform, and the button disappears at the reset state
[auto: smoke_backdrop]
- [ ] **NEW PAPER RULE — the sheet is the rooms** (owner 2026-08-04, changes
the old behaviour): set a loud `bg_color` (or `daynight`) on a space
@@ -1324,7 +1399,7 @@ require hands on real hardware — they remain for the human pass.
arrow points at them together [auto: smoke_backdrop]
- [ ] **The static card and the kiosk agree**: put a `houseplan-space-card` for
the same space on a dashboard and open the kiosk view. Both draw the
picture at the same offset and size, with the same room-contour paper
picture at the same offset, independent size and angle, with the same room-contour paper
[auto: smoke_backdrop, smoke_render_parity]
- [ ] **Old plans are untouched**: a space whose picture has never been moved
renders exactly as before the update — same place, same size. Nothing is
@@ -1567,7 +1642,8 @@ require hands on real hardware — they remain for the human pass.
- [ ] **The wall magnet**: click near a wall — the piece's BACK lands flat on
it and it turns to the wall's direction (a bed's headboard against the
wall, a toilet's cistern against it, a sofa's back against it). Holding
**Shift** at the same place must not disable the wall magnet
**Shift** bypasses the wall magnet but still lands through the
decor/room/grid magnet, never between grid nodes
[auto: smoke_furniture]
- [ ] **The magnet while dragging**: drag a placed sofa across the room to
another wall — it turns to that wall as it arrives. Drag it back into the
@@ -1580,14 +1656,20 @@ require hands on real hardware — they remain for the human pass.
- [ ] **The frame is the text block's frame**: four corner beads and a rotate
handle, the beads a quarter of their hit area. Grab one with a finger on
a tablet, aiming roughly: it is caught [auto: smoke_furniture]
- [ ] **Width and depth are independent**: drag a corner sideways — only the
width changes; drag it down — only the depth. Two live badges show both
in metres (or feet) while you drag, and they match a later measurement
against the grid. Holding **Shift** must not create sizes off the grid
- [ ] **Proportional by default**: drag a corner sideways or down — the current
ratio is preserved about the opposite corner. Hold Shift to change width
and depth independently. Two live badges show both in metres (or feet)
while you drag, and they match a later measurement against the grid;
neither mode creates sizes off the grid
[auto: smoke_furniture]
- [ ] **Rotation**: the handle above the box turns the piece in 5° steps about
its CENTRE (not a corner); Shift goes past the step; turning back to
straight removes the angle entirely [auto: smoke_furniture]
- [ ] **Complete properties**: double click a piece and change its symbol,
width/depth in m/ft, angle, contour colour/opacity and line width in
cm/in. Save keeps its centre/transform and reloads exactly; Cancel changes
nothing; reopening a non-first symbol selects that exact option
[auto: smoke_furniture; manual]
- [ ] **It is only decor**: a piece takes no tap in view mode, has no entity
and no state, and does not appear in any room aggregation. Erase removes
it; Delete removes the selected one [auto: smoke_furniture]
+40 -25
View File
@@ -1,6 +1,6 @@
# House Plan — полное руководство пользователя
Актуально для **v1.59.2**. Руководство заново составлено по исходному коду и фактическому интерфейсу этой версии. Старые README и тематические спецификации использовались только для перепроверки сценариев.
Актуально для **v1.60.0**. Руководство заново составлено по исходному коду и фактическому интерфейсу этой версии. Старые README и тематические спецификации использовались только для перепроверки сценариев.
House Plan — локальная интеграция и две Lovelace-карточки для Home Assistant:
@@ -172,7 +172,7 @@ title: План дома
- если один или несколько объектов далеко от основного плана, появляется подсказка о дальних объектах;
- в киоске при увеличении выше 1:1 горизонтальный жест двигает план, а не листает этажи;
- любая ручная операция в киоске приостанавливает автолистание на 60 секунд;
- `Shift` не отключает позиционную сетку; он используется только для явно угловых действий: свободного поворота декора и грубого шага компаса 15°.
- `Shift` не отключает позиционную сетку; в редакторе подложки он делает прямоугольник квадратом/овал кругом при создании, разрешает непропорциональное масштабирование и свободный поворот; у компаса включает шаг 15°.
### Клавиши отмены
@@ -180,10 +180,10 @@ title: План дома
|---|---|---|---|
| Незавершённый Draw/Split | Убирает последнюю точку или выходит из инструмента | Сначала убирает незавершённую точку | — |
| Любой инструмент редактора плана | Отменяет текущий жест/выбор | Отменяет последнюю именованную геометрическую операцию | Повторяет отменённую операцию |
| Подложка | Отменяет черновик, выбор или текущий инструмент | Отдельной истории отмены нет | — |
| Подложка | Отменяет незавершённое рисование или возвращает состояние до начала активного move/resize/rotate | Отменяет последнюю именованную операцию декора или картинки | Повторяет отменённую операцию |
| Текстовое поле | Закрывает верхний диалог | Обычная отмена текста браузером | Обычный повтор текста браузером |
Общий стек редактора плана хранит 50 операций. В панель также добавлены кнопки **Отменить** и **Повторить**; их подсказка показывает имя следующего шага. История живёт в текущей сессии карточки и безопасно сбрасывается, если с сервера приходит более новая внешняя ревизия плана.
Общий стек геометрии хранит 50 операций и используется редакторами плана и подложки. В обе панели добавлены кнопки **Отменить** и **Повторить**; их подсказка показывает имя следующего шага. История живёт в текущей сессии карточки и безопасно сбрасывается, если с сервера приходит более новая внешняя ревизия плана.
Во всех диалогах `Tab` и `Shift+Tab` перемещают фокус только внутри открытого
окна, `Esc` закрывает верхнее окно, а после закрытия фокус возвращается на
@@ -391,18 +391,25 @@ House Plan читает реестры устройств, сущностей и
| Привязка | Устройство, сущность или виртуальный маркер |
| Комната | Автоматическая HA-зона либо ручное назначение, включая комнату без зоны |
| Действие по нажатию | Карточка House Plan, HA more-info, toggle, запуск сценария или управление шторами |
| Управляет источниками света | Группа `light.*`/`switch.*`, переключаемая вместе |
| Это источник света | Позволяет реле/выключателю создавать световое пятно |
| Управляет другими источниками света | Группа других `light.*`/`switch.*`, переключаемая вместе. Собственная entity и сущности привязанного устройства сюда не входят |
| Это источник света | Явно считает само реле/выключатель источником света и позволяет ему создавать световое пятно |
| Использовать температуру climate | Добавляет `current_temperature` термостата/кондиционера в маркер и среднее комнаты |
| Скрыть / Показать | Кнопка в левом нижнем углу диалога. После сохранения убирает или возвращает значок; LQI скрытого устройства всё ещё может участвовать в среднем комнаты |
| Удалить | Кнопка рядом со «Скрыть». После подтверждения сразу полностью убирает устройство с плана и позволяет позднее добавить его заново |
| Радиус свечения | Локально переопределяет общий радиус, 0.1–100 м в хранилище |
| Иконка | Ручная `mdi:*`; пусто — автоматический подбор |
| Иконка | Ручная `mdi:*`; пусто — автоматический подбор. Автовариант показывается в поле как значок с именем `mdi:*`, но не сохраняется как ручная замена |
| Отображение | Иконка, иконка + активность или числовое значение |
| Размер/поворот | Масштаб маркера и угол |
| Модель, ссылка, описание | Информация во внутренней карточке |
| Файлы | PDF, PNG, JPG/JPEG, WebP, TXT; несколько вложений |
Удаление автоматически найденного маркера фактически скрывает его, чтобы автообнаружение не вернуло значок. Виртуальный маркер удаляется полностью.
Удаление и скрытие — разные операции. Скрытый маркер можно показать через
**Показать скрытые**, и он продолжает участвовать в предусмотренных для скрытых
устройств данных. Удалённый маркер там не показывается, не участвует в LQI,
температуре, влажности, свете, Glow, карточке комнаты, управлении и живых
ссылках плана. Его привязка снова появляется в списке **Добавить**. Повторное
добавление создаёт новую позицию; сохранённая конфигурация проёмов и текстовых
переменных снова оживает. Позиция, вложения и след пылесоса удаляются.
### Скрытые устройства
@@ -450,16 +457,16 @@ House Plan читает реестры устройств, сущностей и
2. **иконка/число** сообщает тип или значение;
3. **кольцо активности** в режиме «Иконка + активность» сообщает событие, присутствие, движение или работу.
Приоритет статусов: **авария → работа → открыто → нейтрально**. Если все сущности, реально описывающие маркер, недоступны, маркер бледнеет.
Приоритет статусов: **авария → работа → открыто → нейтрально**. Если все сущности, реально описывающие маркер, недоступны, маркер бледнеет. У медиаплеера `off` использует то же приглушённое отображение, что `unknown` и `unavailable`, без отдельного визуального состояния.
### Подложка и постоянный статус
| Вид | Что означает | Примеры |
|---|---|---|
| Красная авария | Критическое событие; показывается даже при выключенных живых состояниях | Дым, газ, CO, протечка, tamper/problem/safety, сирена `on`, охранная панель `triggered` |
| Жёлтая подложка | Устройство сейчас выполняет работу | Свет/реле/вентилятор/увлажнитель `on`; climate реально heating/cooling; media playing; vacuum cleaning/returning; script on; техника washing/drying/etc. |
| Жёлтая подложка | Устройство сейчас выполняет работу | Свет/реле/вентилятор/увлажнитель `on`; climate реально heating/cooling; vacuum cleaning/returning; script on; техника washing/drying/etc. |
| Оранжевая подложка | Физически открыто/разблокировано | Датчик двери/окна/гаража `on`, замок unlocked/open, клапан open/opening/closing |
| Бледный маркер | Данные недоступны | Все рабочие сущности маркера `unknown`, `unavailable` или отсутствуют |
| Бледный маркер | Данные недоступны либо медиаплеер выключен | Все рабочие сущности маркера `unknown`, `unavailable` или отсутствуют; `media_player.* = off` |
| Нейтральный | Нет аварии, работы и открытого состояния | Выключено, закрыто, idle/standby/docked и т. п. |
`automation.* = on` означает «автоматизация включена», а не «исполняется», поэтому жёлтая подложка не появляется. `cover.*` специально не получает оранжевую подложку за открытое состояние: открытие показывается формой иконки.
@@ -473,7 +480,7 @@ House Plan читает реестры устройств, сущностей и
| Событие | Около 3.3 секунды | Motion/vibration/sound при срабатывании; открытие контакта; button/event при изменении; ручной запуск сценария из карточки |
| Присутствие | Пока состояние активно | Binary sensor с классом occupancy/presence |
| Переход | Пока идёт движение; при мгновенном переходе около 3.3 секунды | Cover opening/closing, lock locking/unlocking, valve opening/closing, moving sensor, vacuum returning |
| Работа | Пока устройство работает | Свет, switch, fan, humidifier, climate action, media playing, vacuum cleaning, script и известные рабочие состояния техники |
| Работа | Пока устройство работает | Свет, switch, fan, humidifier, climate action, vacuum cleaning, script и известные рабочие состояния техники |
Цвет и размер кольца можно изменить для конкретного маркера. Если цвет не задан, RGB-свет может передать кольцу свой текущий цвет.
@@ -512,7 +519,7 @@ House Plan читает реестры устройств, сущностей и
| `lock.*` | Оранжевый при unlocked/open | Переход при locking/unlocking | Замкнут/открыт | Карточка; Toggle запрещён |
| `valve.*` | Оранжевый при open/opening/closing | Переход при opening/closing | Обычно нет | Карточка; Toggle можно выбрать явно |
| `climate.*` | Жёлтый только при реальном heating/cooling/working и т. п. | Работа по `hvac_action`/аналогичному атрибуту | Нет | Внутренняя карточка |
| `media_player.*` | Жёлтый при `playing` | Работа при `playing` | Нет | Внутренняя карточка |
| `media_player.*` | Нейтральный при любом включённом/транспортном состоянии; приглушённый при `off`, как при `unknown`/`unavailable` | Нет: `playing`, вспомогательная подсветка и служебные переключатели не считаются работой | Нет | Внутренняя карточка |
| `vacuum.*` | Жёлтый при cleaning и returning | Работа при cleaning, переход при returning | Нет | Внутренняя карточка; движущийся puck открывает HA more-info |
| `script.*` | Жёлтый, пока `on` | Работа, пока выполняется | Нет | Внутренняя карточка или явный Run |
| `automation.*` | Нейтральный даже при `on` | Короткое событие после явного Run из плана | Нет | Внутренняя карточка или явный Run |
@@ -571,25 +578,32 @@ House Plan читает реестры устройств, сущностей и
| Инструмент | Создание | Редактирование |
|---|---|---|
| Выбрать | Клик выбирает объект | Drag перемещает; двойной клик всегда открывает свойства; `Delete`/`Backspace` удаляет |
| Подложка | Доступна, если есть изображение | Drag перемещает изображение, угловая ручка равномерно масштабирует; позиционная привязка обязательна |
| Выбрать | Клик выбирает объект | Drag перемещает; общая рамка масштабирует пропорционально и поворачивает; `Shift` разрешает разные оси; двойной клик открывает свойства; `Delete`/`Backspace` удаляет |
| Подложка | Доступна, если есть изображение | Drag перемещает изображение, угловые ручки масштабируют, верхняя поворачивает; `Shift` разрешает разные оси/свободный угол; двойной клик открывает числовые размеры и угол |
| Линия | Drag между двумя точками | Цвет и толщина; показываются длина и угол |
| Прямоугольник | Drag по диагонали | Цвет, толщина и заливка; показываются ширина × высота |
| Овал | Drag по габаритам | Цвет, толщина и заливка; показываются ширина × высота |
| Текст | Клик открывает диалог | Многострочный текст, переменные HA, цвет; масштаб и поворот ручками |
| Мебель | Выбор символа и клик по центру | Размер, цвет, толщина, поворот; магнитится к стене |
| Стереть | Клик по объекту | Удаляет сразу |
| Прямоугольник | Drag по диагонали; `Shift` фиксирует квадрат | Контур и независимая заливка с прозрачностью; показываются ширина × высота и площадь |
| Овал | Drag по габаритам; `Shift` фиксирует круг | Контур и независимая заливка с прозрачностью; показывается `R` либо `Rx × Ry` |
| Текст | Клик открывает диалог | Многострочный текст, переменные HA, цвет/прозрачность, физический размер и угол; масштаб и поворот ручками |
| Мебель | Выбор символа и клик по центру | Символ, размер, цвет, толщина, поворот; магнитится к стене |
| Стереть | Клик по объекту | После подтверждения удаляет объект; действие можно отменить через Undo |
Новые фигуры создаются не меньше половины клетки. После создания объект остаётся выбранным. Создание, движение и изменение размера всегда квантуются по сетке.
Новые фигуры создаются не меньше половины клетки. После создания объект остаётся выбранным. Создание, движение и изменение размера всегда квантуются по сетке. При рисовании и перемещении действует лёгкий магнит к углам, серединам, центрам и рёбрам других объектов декора и комнат; устройства, проёмы и картинка-подложка не являются целями магнита.
Картинка подложки интерактивна только в своём инструменте. В остальных инструментах этого редактора она показана с непрозрачностью `0.5`, а в просмотре и других редакторах — `1.0`.
Цвет контура/текста/мебели и его прозрачность задаются общим компактным полем: нажмите на образец цвета, чтобы открыть выбор цвета и прозрачности. Толщина и размер текста хранятся физически и показываются в сантиметрах или дюймах. Для прямоугольника и овала заливка имеет собственные цвет и прозрачность. Большие размеры в свойствах показываются в метрах или футах.
Редактор подложки использует тот же именованный Undo/Redo на 50 команд, что и геометрия плана. `Esc` отменяет только незавершённое рисование или текущий drag/resize/поворот; после отпускания указателя используйте `Ctrl+Z`/`Cmd+Z`. Повтор — `Ctrl+Shift+Z` либо `Ctrl+Y`.
### Двойной клик в «Выбрать»
| Объект | Что открывается |
|---|---|
| Текст | Полный диалог текста и HA-переменных |
| Линия | Цвет и толщина линии |
| Прямоугольник/овал | Цвет, толщина и заливка |
| Мебель | Цвет и толщина контура |
| Линия | Длина, угол, цвет/прозрачность и физическая толщина; концы также двигаются отдельными ручками |
| Прямоугольник/овал | Ширина, высота, угол, контур и независимая заливка с прозрачностью |
| Мебель | Символ, ширина, глубина, угол, цвет/прозрачность и физическая толщина контура |
| Картинка-подложка | Ширина, высота и угол (двойной клик в инструменте «Подложка») |
### Живой текст Home Assistant
@@ -627,7 +641,8 @@ House Plan читает реестры устройств, сущностей и
- прижимается к ней и поворачивается параллельно;
- всегда прижимается к ближайшей стене в радиусе магнита, иначе размещается по сетке;
- меняет ширину и глубину независимыми угловыми ручками;
- с зажатым `Shift` пропускает стеновой магнит, но всё равно привязывается к декору, комнатам и сетке;
- угловыми ручками по умолчанию сохраняет пропорции, а с `Shift` меняет ширину и глубину независимо;
- поворачивается с шагом 5°, а с `Shift` — свободно.
## 15. Солнце и фон день/ночь
+10 -5
View File
@@ -103,19 +103,24 @@ layer you cannot see is a layer you cannot edit.
## Background — the decor underlay
- Toolbar tools: select / line / rect / oval / text / erase, plus colour, width
and fill. Shapes are drag-drawn with grid snap and a live preview.
- Toolbar tools: Select / optional Plan backdrop / Line / Rectangle / Oval /
Text / Furniture / Erase, plus contour colour+opacity, physical width,
optional fill colour+opacity and shared Undo/Redo. Shapes are drag-drawn
with grid/decor/room snap and a live preview.
- In **Select**, double click always means “edit this object”. Text opens its
text/HA-variable form; a line, rectangle, oval or furniture symbol opens the
common colour and line-width form, with Fill where the shape supports it.
complete numeric/style form, with Fill where the shape supports it. Every
selected kind has the common move/resize/rotate controller; lines use end
handles. The image is transformed only in its own tool and fades to 0.5
below the other Background tools.
- **Live size badge** (owner 2026-08-04): while a LINE is being dragged out, the
same `.measurelabel` the Plan editor puts on a wall shows «length · angle» —
`segmentCm` over the space's `cell_cm`, metres or feet per the HA unit system,
green on a 45° multiple. It rides the MIDDLE of the segment (a wall badge
follows the cursor because the cursor is the wall's free end; a decor line is
pulled out by both ends at once), updates on every move and disappears the
moment the shape is committed. Rectangles and ovals have no length, so they
show their bounding box «W × H» instead; a draft with no size shows nothing.
moment the shape is committed. Rectangles show size and area; circles show
`R`, and non-circular ovals `Rx × Ry`; a draft with no size shows nothing.
## Deprecations decided
+5
View File
@@ -76,6 +76,11 @@ 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.
Deleting a vacuum marker is stronger than hiding it: its binding tombstone
stops all plan rendering/aggregation, its layout is removed, and both stored
server runs (current and previous) are erased. Re-adding starts uncalibrated at
a fresh position; the old trail is not resurrected.
## Trail (ships in P1)
- Integration `path` when available (Map Extractor) — transform and
+22 -1
View File
@@ -137,6 +137,26 @@ Lovelace **пересоздаёт элемент карточки** — выбр
из осевшей геометрии (`_bootSettled`), поэтому холодный бут по-прежнему платит
полный защитный цикл.
### 2.1. Возврат после долгого сна вкладки
Тёплый viewport решает пересоздание экземпляра, но после долгого background
браузер, HA chrome и websocket могут проснуться в разных кадрах. В этот момент
`ResizeObserver` способен выдать нулевой или промежуточный размер сцены. Такой
размер больше никогда не меняет `_view`.
Если вкладка была скрыта не меньше 15 секунд, обычный режим Просмотра включает
короткий `_resumeSettling`: содержимое сцены остаётся за её уже правильным
фоном, пока размер не станет ненулевым и спокойным. После этого сохранённые
центр и zoom пересчитываются по окончательной геометрии и открываются одним
кадром. Памятка `warmBoot` во время промежуточного кадра не обновляется.
Быстрое переключение вкладок, редакторы и киоск в этот путь не входят.
Толстые стены дополнительно имеют критические `fill`, `stroke`, opacity и
`fill-rule` как SVG presentation attributes. Поэтому даже промежуточный кадр
нового DOM-экземпляра не может получить браузерную заливку `black` до того,
как применятся стили компонента; CSS hooks при этом сохранены и по обычному
каскаду остаются сильнее presentation attributes.
## 3. Диалоги переживают пересоздание
В той же записи памятки лежит `dlg`: `{ kind, space, mode, data }`, где
@@ -170,7 +190,8 @@ Lovelace **пересоздаёт элемент карточки** — выбр
`_spaceDialog` (настройки/создание пространства), `_markerDialog` (устройство),
`_settingsDialog` (общие настройки), `_rulesDialog` (правила иконок),
`_openingDialog` (проём), `_decorTextDialog` (надпись), `_decorShapeDialog`
(цвет / толщина линии объекта подложки), `_roomDialog` вместе со всей своей
(полные свойства объекта подложки), `_backdropDialog` (числовая трансформация
картинки), `_roomDialog` вместе со всей своей
обвязкой (`_roomEditId`, `_roomFill`, источники температуры и влажности,
масштабы подписей, `_areaSel`/`_nameSel`, `_pendingSplit`, `_path`).
@@ -64,7 +64,6 @@ activity: 'none' | 'event' | 'presence' | 'transition' | 'running'
- `light/switch/fan/humidifier = on`;
- climate: `hvac_action = heating/cooling/drying/fan`, но не просто выбранный
режим `heat/cool/auto`;
- media player: `playing`;
- vacuum: `cleaning` (и работающий механизм при `returning`);
- бытовая техника: `running/washing/rinsing/spinning/drying/heating/cooking`;
- маркер с `controls`: работает хотя бы одна управляемая цель.
@@ -72,6 +71,14 @@ activity: 'none' | 'event' | 'presence' | 'transition' | 'running'
Не являются работой: открытая дверь, разблокированный замок, открытые шторы,
presence/motion, `enabled`, `home`, `unknown`, `unavailable`, climate `idle`.
`media_player` — пассивный медиатранспорт, а не исполнительный механизм:
`on/idle/playing/paused/standby` остаются нейтральными и не получают running-
активность, а явный `off` использует то же приглушённое отображение, что
`unknown/unavailable`. Правило действует на весь домен (телевизоры, ресиверы,
колонки, саундбары), не на конкретные модели, и не вводит отдельного статуса.
Если у маркера несколько `media_player`, он бледнеет только когда среди них
нет доступной и включённой сущности.
В glow-режиме у реального источника света жёлтая подложка может быть скрыта:
его устойчивым индикатором служит световое пятно. Эффект активности остаётся.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.59.2",
"version": "1.60.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.59.2",
"version": "1.60.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.59.2",
"version": "1.60.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+23 -6
View File
@@ -1,8 +1,25 @@
// tsc keeps extensionless relative imports; Node ESM requires explicit ".js".
// Walk the complete output tree: feature modules may import siblings from a
// nested directory, and a root-only pass leaves those suites unable to start.
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
for (const f of readdirSync('test-build')) {
if (!f.endsWith('.js')) continue;
const p = `test-build/${f}`;
const s = readFileSync(p, 'utf8').replace(/from\s+'(\.\/[\w-]+)'/g, "from '$1.js'");
writeFileSync(p, s);
}
import { extname, join } from 'node:path';
const patchImports = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
patchImports(path);
continue;
}
if (!entry.name.endsWith('.js')) continue;
const source = readFileSync(path, 'utf8').replace(
/(from\s+['"]|import\s*\(\s*['"])(\.\.?\/[^'"]+)(['"])/g,
(match, prefix, specifier, suffix) => (
extname(specifier) ? match : `${prefix}${specifier}.js${suffix}`
),
);
writeFileSync(path, source);
}
};
patchImports('test-build');
+7 -3
View File
@@ -159,9 +159,13 @@ export function entityVisualSample(hass: any, eid: string): EntityVisualSample {
}
if (domain === 'media_player') {
return state === 'playing'
? { ...base, status: 'working', activity: 'running' }
: base;
// A media-player state describes its power/transport lifecycle, not an
// actuator doing work. In particular, `playing` must not turn every TV,
// receiver or soundbar into a permanent yellow "working" marker. Keep
// every powered/transport state neutral. An explicit `off` uses the same
// existing faded presentation as unknown/unavailable: this is a visual
// availability flag, not another status in the marker state machine.
return state === 'off' ? { ...base, availability: 'unavailable' } : base;
}
if (domain === 'vacuum') {
+146 -14
View File
@@ -61,8 +61,8 @@ export function isTempEntity(hass: any, eid: string): boolean {
* outrank the vacuum/climate/cover/etc. entity it configures.
*/
const DEVICE_STATE_DOMAINS = [
'vacuum', 'lawn_mower', 'climate', 'light', 'cover', 'lock', 'valve',
'alarm_control_panel', 'water_heater', 'media_player', 'fan', 'humidifier',
'vacuum', 'lawn_mower', 'climate', 'media_player', 'light', 'cover', 'lock', 'valve',
'alarm_control_panel', 'water_heater', 'fan', 'humidifier',
'siren', 'camera', 'remote',
];
@@ -147,7 +147,10 @@ export interface LightSourceDevice {
hidden?: boolean;
entities: string[];
primary?: string;
/** Runtime-effective controls; marker.controls remains the persisted list. */
controls?: string[];
marker?: {
binding?: string;
room_id?: string | null;
is_light?: boolean | null;
controls?: string[] | null;
@@ -166,6 +169,24 @@ export interface ResolvedLightSource<D extends LightSourceDevice = LightSourceDe
on: boolean;
}
/**
* Controls are OTHER light targets operated by this marker. A marker already
* operates its own bound entity/device through the normal tap action; storing
* one of those same entities in controls made a plain fan/socket look like an
* explicitly configured light source. `is_light` is the sole explicit way to
* say that the bound switch itself drives a real fixture.
*/
export function effectiveMarkerControls(
binding: string | null | undefined,
controls: readonly string[] | null | undefined,
ownEntities: readonly string[] = [],
): string[] {
const own = new Set(ownEntities);
if (binding?.startsWith('entity:')) own.add(binding.slice('entity:'.length));
return [...new Set(controls || [])]
.filter((eid) => isControllable(eid) && !own.has(eid));
}
function lightSourceBelongsToRoom(d: LightSourceDevice, room: LightSourceRoom): boolean {
if (typeof room === 'string') return d.area === room;
// An explicit marker-to-room binding is more precise than the HA area and
@@ -175,8 +196,12 @@ function lightSourceBelongsToRoom(d: LightSourceDevice, room: LightSourceRoom):
return !!room.area && d.area === room.area;
}
function lightEntitiesOf(d: LightSourceDevice): { eids: string[]; via: ResolvedLightSource['via'] } {
const controls = (d.marker?.controls || []).filter(isControllable);
function lightEntitiesOf(hass: any, d: LightSourceDevice): { eids: string[]; via: ResolvedLightSource['via'] } {
const controls = effectiveMarkerControls(
d.marker?.binding,
d.controls ?? d.marker?.controls,
d.entities,
);
if (controls.length) return { eids: controls, via: 'controls' };
if (d.marker?.is_light === true) {
@@ -190,6 +215,15 @@ function lightEntitiesOf(d: LightSourceDevice): { eids: string[]; via: ResolvedL
return { eids: primary ? [primary] : controllable.slice(0, 1), via: 'forced' };
}
// Automatic light discovery describes the DEVICE, not every auxiliary
// entity it happens to expose. TVs, soundbars, air cleaners and similar
// devices often publish a light.* for a status LED / display illumination;
// letting that entity win turned the whole marker yellow and made it a room
// light source. The resolved functional role is the shared systemic answer.
// Explicit controls/is_light above still override it when the user says the
// auxiliary light really is a plan light.
const role = d.primary || resolvedDeviceStateEntities(hass, d.entities)[0];
if (role && !role.startsWith('light.')) return { eids: [], via: 'light' };
return { eids: d.entities.filter((eid) => eid.startsWith('light.')), via: 'light' };
}
@@ -208,7 +242,7 @@ export function resolvedLightSources<D extends LightSourceDevice>(
const seen = new Set<string>();
for (const d of devices) {
if (d.hidden || (room != null && !lightSourceBelongsToRoom(d, room))) continue;
const { eids, via } = lightEntitiesOf(d);
const { eids, via } = lightEntitiesOf(hass, d);
for (const eid of eids) {
if (!eid || seen.has(eid)) continue;
seen.add(eid);
@@ -348,8 +382,86 @@ export function resolveIcon(hass: any, name: string, model: string | undefined,
return iconFromDeviceClasses(classes) ?? FALLBACK_ICON;
}
function applyMarker(item: DevItem, m: Marker): void {
export interface RemovedPlanBindings {
devices: Set<string>;
entities: Set<string>;
}
export interface DeletePlanMarkerResult {
markers: Marker[];
cleanupIds: Set<string>;
}
/** Remove one marker binding without letting the shared literal `virtual`
* identify every manual marker. Real HA bindings are unique and therefore
* deduplicated; virtual markers have identity only by id. Non-virtual
* deletion leaves a hidden tombstone so an older cached card degrades to a
* hidden marker instead of visibly resurrecting it. */
export function deletePlanMarkerRecords(
markers: readonly Marker[], id: string, binding: string, virtual: boolean,
): DeletePlanMarkerResult {
const cleanupIds = new Set<string>([id]);
const kept = markers.filter((marker) => {
const drop = marker.id === id || (!virtual && marker.binding === binding);
if (drop) cleanupIds.add(marker.id);
return !drop;
});
return {
markers: virtual
? kept
: [...kept, { id, binding, removed: true, hidden: true }],
cleanupIds,
};
}
/** Bindings explicitly deleted from the plan, kept as minimal tombstones. */
export function removedPlanBindings(markers?: readonly Marker[] | null): RemovedPlanBindings {
const devices = new Set<string>();
const entities = new Set<string>();
for (const m of markers || []) {
if (m?.removed !== true) continue;
const i = String(m.binding || '').indexOf(':');
if (i < 1) continue;
const kind = m.binding.slice(0, i);
const ref = m.binding.slice(i + 1);
if (!ref) continue;
if (kind === 'device') devices.add(ref);
else if (kind === 'entity') entities.add(ref);
}
return { devices, entities };
}
/** Whether an HA entity is suppressed by an entity or whole-device tombstone. */
export function isRemovedPlanEntity(
hass: any, eid: string, removed: RemovedPlanBindings,
): boolean {
if (removed.entities.has(eid)) return true;
const deviceId = hass?.entities?.[eid]?.device_id;
return !!deviceId && removed.devices.has(deviceId);
}
/** Whether a `device:*` / `entity:*` source is deleted from the plan. */
export function isRemovedPlanSource(
hass: any, source: string | null | undefined, markers?: readonly Marker[] | null,
): boolean {
if (!source) return false;
const i = source.indexOf(':');
if (i < 1) return false;
const kind = source.slice(0, i);
const ref = source.slice(i + 1);
const removed = removedPlanBindings(markers);
if (kind === 'device') return removed.devices.has(ref);
return kind === 'entity' && isRemovedPlanEntity(hass, ref, removed);
}
function applyMarker(item: DevItem, m: Marker, hass: any, removed: RemovedPlanBindings): void {
const controls = effectiveMarkerControls(m.binding, m.controls, item.entities)
.filter((eid) => !isRemovedPlanEntity(hass, eid, removed));
// Keep persisted configuration lossless. Runtime consumers use the filtered
// projection above; the dialog edits the original list and therefore cannot
// silently write a temporarily suppressed control out of the marker.
item.marker = m;
item.controls = controls;
if (m.hidden) item.hidden = true;
if (m.name) item.name = m.name;
if (m.icon) item.icon = m.icon;
@@ -370,7 +482,9 @@ function applyMarker(item: DevItem, m: Marker): void {
export function seedHiddenBindings(ctx: Omit<BuildCtx, 'showAll' | 'loc'>): string[] {
const { hass: h, areaToSpace, markers, settings, excluded, iconRules } = ctx;
const groupLights = settings.group_lights !== false;
const groups = lightGroups(h, groupLights);
const removed = removedPlanBindings(markers);
const groups = lightGroups(h, groupLights)
.filter((g) => !isRemovedPlanEntity(h, g.eid, removed));
const groupedAreas = new Set(groups.map((g) => g.area));
const entsBy = entitiesByDevice(h);
const marked = new Set(markers.map((m) => m.binding));
@@ -380,6 +494,8 @@ export function seedHiddenBindings(ctx: Omit<BuildCtx, 'showAll' | 'loc'>): stri
if (!area || !areaToSpace[area]) continue;
if (dev.entry_type === 'service') continue;
if (marked.has('device:' + dev.id)) continue;
// An entity tombstone suppresses that standalone binding, not the same
// entity as data belonging to a still-live parent device.
const entIds = entsBy[dev.id] || [];
const dom = domainOfDevice(h, dev, entIds);
let nonPhysical =
@@ -401,7 +517,9 @@ export function seedHiddenBindings(ctx: Omit<BuildCtx, 'showAll' | 'loc'>): stri
export function buildDevices(ctx: BuildCtx): DevItem[] {
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
const groupLights = settings.group_lights !== false;
const groups = lightGroups(h, groupLights);
const removed = removedPlanBindings(markers);
const groups = lightGroups(h, groupLights)
.filter((g) => !isRemovedPlanEntity(h, g.eid, removed));
const groupedAreas = new Set(groups.map((g) => g.area));
const entsBy = entitiesByDevice(h);
const claimed = new Set<string>();
@@ -480,6 +598,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
// 3) explicit markers (rebinding/metadata/virtual)
for (const m of markers) {
if (m.removed) continue;
// Hidden is a FLAG now, not an absence: the device is built (room LQI
// still counts it) and the renderer decides. Legacy configs keep the old
// "hidden = gone" until they are seeded (docs/FILTERING.md).
@@ -489,7 +608,9 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
const dev = h.devices[ref];
const area = m.area || dev?.area_id || '';
const space = (area && areaToSpace[area]) || m.space || firstSpaceId;
const entIds = dev ? entsBy[dev.id] || [] : [];
const entIds = dev
? (entsBy[dev.id] || [])
: [];
let icon = dev
? resolveIcon(h, dev.name_by_user || dev.name || '', dev.model, entIds, iconRules)
: 'mdi:help-circle';
@@ -509,9 +630,10 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
applyMarker(item, m);
applyMarker(item, m, h, removed);
rest.push(item);
} else if (kind === 'entity') {
if (isRemovedPlanEntity(h, ref, removed)) continue;
const reg = h.entities[ref];
const area = m.area || reg?.area_id || (reg?.device_id && h.devices[reg.device_id]?.area_id) || '';
const space = (area && areaToSpace[area]) || m.space || firstSpaceId;
@@ -533,7 +655,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
};
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, [ref]);
if (isHumEntity(h, ref)) item.hum = humFor(h, [ref]);
applyMarker(item, m);
applyMarker(item, m, h, removed);
rest.push(item);
} else {
// virtual
@@ -550,7 +672,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
bindingKind: 'virtual',
virtual: true,
};
applyMarker(item, m);
applyMarker(item, m, h, removed);
rest.push(item);
}
}
@@ -570,8 +692,12 @@ export function areaLights(hass: any, devices: readonly LightSourceDevice[], are
* number; 'device:<id>' aggregates over that device's entities (tempFor /
* humFor). Used by the room-settings override (tier 3).
*/
export function sourceValue(hass: any, src: string | null | undefined, kind: 'temp' | 'hum'): number | null {
export function sourceValue(
hass: any, src: string | null | undefined, kind: 'temp' | 'hum',
markers?: readonly Marker[] | null,
): number | null {
if (!src) return null;
if (isRemovedPlanSource(hass, src, markers)) return null;
const i = src.indexOf(':');
if (i < 0) return null;
const k = src.slice(0, i);
@@ -654,15 +780,21 @@ export function areaClimateMap(
// the option, that reading votes in the room average like any thermometer.
// The set holds binding refs (device ids and entity ids); with no opted
// markers the pass below is byte-for-byte the old one.
const removed = removedPlanBindings(markers);
const climOpt = new Set<string>();
for (const m of markers || []) {
if (m?.use_climate_temp !== true) continue;
if (m?.removed || m?.use_climate_temp !== true) continue;
const i = (m.binding || '').indexOf(':');
if (i > 0) climOpt.add(m.binding.slice(i + 1));
}
// area -> device (or lone entity) -> the entities that belong to it
const byArea = new Map<string, Map<string, { name: string; model?: string; ents: string[] }>>();
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
// A device tombstone suppresses all of its data. An entity tombstone only
// suppresses a standalone entity; inside its live parent device it remains
// available to device state, cards and room aggregates.
if ((reg.device_id && removed.devices.has(reg.device_id))
|| (!reg.device_id && removed.entities.has(eid))) continue;
const dev = reg.device_id ? hass.devices?.[reg.device_id] : null;
const area = reg.area_id || dev?.area_id || null;
if (!area) continue;
+300
View File
@@ -0,0 +1,300 @@
import type { DecorShape, DecorStyle } from './types';
export interface DecorBox {
x: number;
y: number;
w: number;
h: number;
angle?: number;
}
export interface SnapSegment {
a: number[];
b: number[];
}
export interface SnapGeometry {
points: number[][];
segments: SnapSegment[];
}
export interface SnapResult {
point: number[];
target: number[] | null;
kind: 'grid' | 'point' | 'edge';
}
export const DEFAULT_DECOR_STYLE: DecorStyle = {
color: '#607d8b',
opacity: 1,
// The old default was 3 render units. On the default 5 cm/cell plan this
// is 3.6 cm, retained so a new canonical object looks familiar.
widthCm: 3.6,
fill: false,
fillColor: '#607d8b',
fillOpacity: 0.25,
};
export const clamp01 = (value: unknown, fallback = 1): number => {
const n = Number(value);
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : fallback;
};
export const normalizeAngle = (value: unknown): number => {
let n = Number(value);
if (!Number.isFinite(n)) return 0;
n = ((n % 360) + 360) % 360;
return n > 180 ? n - 360 : n;
};
/**
* Axis-aligned boxes keep their stored top-left on the lattice. For an
* oriented resize, however, resizeDecorBox has already derived this top-left
* from the fixed opposite corner. Snapping it again translates the whole box
* and breaks the fixed-corner contract.
*/
export function resizedBoxTopLeft(
box: Pick<DecorBox, 'x' | 'y'>,
angle: unknown,
grid: (point: number[]) => number[],
): number[] {
return normalizeAngle(angle) ? [box.x, box.y] : grid([box.x, box.y]);
}
/** A rectangle/ellipse must have two non-zero axes; a line only needs length. */
export function validDecorDraft(
kind: 'line' | 'rect' | 'ellipse',
a: number[], b: number[], minSize: number,
): boolean {
const dx = Math.abs(b[0] - a[0]), dy = Math.abs(b[1] - a[1]);
if (kind === 'line') return Math.hypot(dx, dy) >= minSize;
return dx >= minSize && dy >= minSize;
}
/** Render units represented by a physical number of centimetres. */
export const decorCmToUnits = (cm: unknown, cellCm: number, gridPitch: number): number => {
const n = Number(cm);
const cell = Number.isFinite(cellCm) && cellCm > 0 ? cellCm : 5;
return Number.isFinite(n) && n > 0 ? (n / cell) * gridPitch : 0;
};
/** Physical centimetres represented by render units. */
export const decorUnitsToCm = (units: unknown, cellCm: number, gridPitch: number): number => {
const n = Number(units);
const cell = Number.isFinite(cellCm) && cellCm > 0 ? cellCm : 5;
return Number.isFinite(n) && gridPitch > 0 ? (n / gridPitch) * cell : 0;
};
/** Canonical width wins; legacy width remains pixel-identical until edited. */
export const decorStrokeCm = (
shape: Pick<DecorShape, 'width_cm' | 'width'> | null | undefined,
cellCm: number,
gridPitch: number,
fallback = DEFAULT_DECOR_STYLE.widthCm,
): number => {
const canonical = Number(shape?.width_cm);
if (Number.isFinite(canonical) && canonical > 0) return canonical;
const legacy = Number(shape?.width);
if (Number.isFinite(legacy) && legacy > 0) return decorUnitsToCm(legacy, cellCm, gridPitch);
return fallback;
};
export const decorStrokeUnits = (
shape: Pick<DecorShape, 'width_cm' | 'width'> | null | undefined,
cellCm: number,
gridPitch: number,
fallback = DEFAULT_DECOR_STYLE.widthCm,
): number => {
const canonical = Number(shape?.width_cm);
if (Number.isFinite(canonical) && canonical > 0)
return decorCmToUnits(canonical, cellCm, gridPitch);
const legacy = Number(shape?.width);
if (Number.isFinite(legacy) && legacy > 0) return legacy;
return decorCmToUnits(fallback, cellCm, gridPitch);
};
export function decorStyleOf(
shape: DecorShape | null | undefined,
cellCm: number,
gridPitch: number,
fallback: DecorStyle = DEFAULT_DECOR_STYLE,
): DecorStyle {
const fillable = shape?.kind === 'rect' || shape?.kind === 'ellipse';
const source = shape as any;
const color = /^#[0-9a-f]{6}$/i.test(String(source?.color || ''))
? String(source.color) : fallback.color;
return {
color,
opacity: clamp01(source?.opacity, fallback.opacity),
widthCm: decorStrokeCm(shape, cellCm, gridPitch, fallback.widthCm),
fill: fillable ? source?.fill === true : false,
fillColor: /^#[0-9a-f]{6}$/i.test(String(source?.fill_color || ''))
? String(source.fill_color) : (source?.fill ? color : fallback.fillColor),
// Legacy fill was hard-coded to 0.25.
fillOpacity: fillable && source?.fill
? clamp01(source?.fill_opacity, 0.25)
: fallback.fillOpacity,
};
}
/** Canonical persisted style. Legacy `width` is intentionally removed. */
export function decorStylePatch(style: DecorStyle, fillable: boolean): Record<string, unknown> {
return {
color: style.color,
opacity: clamp01(style.opacity),
width_cm: Math.max(0.1, Math.min(100, Number(style.widthCm) || 0.1)),
...(fillable ? {
fill: style.fill,
fill_color: style.fillColor,
fill_opacity: clamp01(style.fillOpacity, 0.25),
} : {}),
};
}
export function boxCorners(box: DecorBox): number[][] {
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
const a = (normalizeAngle(box.angle) * Math.PI) / 180;
const cs = Math.cos(a), sn = Math.sin(a);
const rot = (x: number, y: number): number[] => {
const dx = x - cx, dy = y - cy;
return [cx + dx * cs - dy * sn, cy + dx * sn + dy * cs];
};
return [
rot(box.x, box.y), rot(box.x + box.w, box.y),
rot(box.x + box.w, box.y + box.h), rot(box.x, box.y + box.h),
];
}
export function boxAnchors(box: DecorBox): SnapGeometry {
const c = boxCorners(box);
const mid = (a: number[], b: number[]) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const center = [(c[0][0] + c[2][0]) / 2, (c[0][1] + c[2][1]) / 2];
return {
points: [...c, mid(c[0], c[1]), mid(c[1], c[2]), mid(c[2], c[3]), mid(c[3], c[0]), center],
segments: c.map((p, i) => ({ a: p, b: c[(i + 1) % 4] })),
};
}
/** Resize an oriented box about the opposite corner. */
export function resizeDecorBox(
orig: DecorBox,
sgx: number,
sgy: number,
px: number,
py: number,
keepAspect: boolean,
step: number,
minSize: number,
): DecorBox {
const a = (normalizeAngle(orig.angle) * Math.PI) / 180;
const ux = Math.cos(a), uy = Math.sin(a);
const vx = -Math.sin(a), vy = Math.cos(a);
const cx = orig.x + orig.w / 2, cy = orig.y + orig.h / 2;
const flx = sgx > 0 ? -orig.w / 2 : orig.w / 2;
const fly = sgy > 0 ? -orig.h / 2 : orig.h / 2;
const fx = cx + flx * ux + fly * vx;
const fy = cy + flx * uy + fly * vy;
const rx = px - fx, ry = py - fy;
let w = (rx * ux + ry * uy) * (sgx > 0 ? 1 : -1);
let h = (rx * vx + ry * vy) * (sgy > 0 ? 1 : -1);
let proportionalScale: number | null = null;
if (keepAspect) {
const kx = w / Math.max(orig.w, minSize);
const ky = h / Math.max(orig.h, minSize);
proportionalScale = Math.max(
minSize / Math.max(orig.w, minSize),
minSize / Math.max(orig.h, minSize),
kx, ky,
);
w = orig.w * proportionalScale;
h = orig.h * proportionalScale;
}
if (step > 0) {
if (keepAspect) {
const wCells = Math.max(1, Math.round(orig.w / step));
const hCells = Math.max(1, Math.round(orig.h / step));
const aligned = Math.abs(orig.w - wCells * step) < 1e-6
&& Math.abs(orig.h - hCells * step) < 1e-6;
if (aligned) {
// A proportional box can keep BOTH dimensions on the grid only at a
// common scale increment. For a 36×18-cell sofa that increment is
// 1/18; snapping just the dominant side would leave the depth halfway
// between cells. Shift remains the independent-axis escape hatch.
const gcd = (a: number, b: number): number => {
let x = Math.abs(a), y = Math.abs(b);
while (y) [x, y] = [y, x % y];
return Math.max(1, x);
};
const scaleStep = 1 / gcd(wCells, hCells);
const minimumScale = Math.max(minSize / orig.w, minSize / orig.h);
const scale = Math.max(
minimumScale,
Math.round((proportionalScale ?? 1) / scaleStep) * scaleStep,
);
w = orig.w * scale;
h = orig.h * scale;
} else {
// Legacy off-grid boxes cannot satisfy exact ratio + two-axis grid at
// once. Preserve their ratio and snap the visually dominant side.
const dominantW = orig.w >= orig.h;
if (dominantW) {
w = Math.round(w / step) * step;
h = w * (orig.h / Math.max(orig.w, minSize));
} else {
h = Math.round(h / step) * step;
w = h * (orig.w / Math.max(orig.h, minSize));
}
}
} else {
w = Math.round(w / step) * step;
h = Math.round(h / step) * step;
}
}
w = Math.max(minSize, w);
h = Math.max(minSize, h);
const ncx = fx + (sgx > 0 ? w / 2 : -w / 2) * ux + (sgy > 0 ? h / 2 : -h / 2) * vx;
const ncy = fy + (sgx > 0 ? w / 2 : -w / 2) * uy + (sgy > 0 ? h / 2 : -h / 2) * vy;
return { x: ncx - w / 2, y: ncy - h / 2, w, h, angle: normalizeAngle(orig.angle) || undefined };
}
const closestOnSegment = (p: number[], a: number[], b: number[]): number[] => {
const dx = b[0] - a[0], dy = b[1] - a[1];
const d2 = dx * dx + dy * dy;
if (d2 < 1e-12) return [...a];
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / d2));
return [a[0] + dx * t, a[1] + dy * t];
};
/**
* Light magnetism that never violates the grid invariant. Candidate points and
* edge projections are quantised through the caller's `grid` function before
* they can win.
*/
export function snapDecorPoint(
raw: number[], geometry: SnapGeometry, tolerance: number,
grid: (point: number[]) => number[],
): SnapResult {
const base = grid(raw);
let best: { p: number[]; d: number; kind: 'point' | 'edge'; target: number[] } | null = null;
for (const target of geometry.points) {
const p = grid(target);
const d = Math.hypot(p[0] - raw[0], p[1] - raw[1]);
if (d <= tolerance && (!best || d < best.d)) best = { p, d, kind: 'point', target };
}
for (const edge of geometry.segments) {
const target = closestOnSegment(raw, edge.a, edge.b);
const p = grid(target);
const d = Math.hypot(p[0] - raw[0], p[1] - raw[1]);
if (d <= tolerance && (!best || d < best.d)) best = { p, d, kind: 'edge', target };
}
return best
? { point: best.p, target: best.target, kind: best.kind }
: { point: base, target: null, kind: 'grid' };
}
export function mergeSnapGeometry(parts: ReadonlyArray<SnapGeometry>): SnapGeometry {
return {
points: parts.flatMap((p) => p.points),
segments: parts.flatMap((p) => p.segments),
};
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Typed persisted model of the decorative layer.
*
* Coordinates stay normalised against the square plan canvas. Physical
* presentation (`width_cm`) is deliberately separate from geometry: resizing
* an object must never make its outline thicker.
*/
export type DecorKind = 'line' | 'rect' | 'ellipse' | 'text' | 'furniture';
export interface DecorBase {
id: string;
kind: DecorKind;
color?: string;
/** Opacity of the stroke, glyphs or furniture drawing. */
opacity?: number;
/** Canonical physical stroke width. Old plans may still carry `width`. */
width_cm?: number;
/** Legacy SVG/render-unit stroke width. Read-only compatibility. */
width?: number;
}
export interface DecorLine extends DecorBase {
kind: 'line';
x1: number;
y1: number;
x2: number;
y2: number;
}
export interface DecorBoxBase extends DecorBase {
x: number;
y: number;
w: number;
h: number;
angle?: number;
}
export interface DecorRect extends DecorBoxBase {
kind: 'rect';
fill?: boolean;
fill_color?: string;
fill_opacity?: number;
}
export interface DecorEllipse extends DecorBoxBase {
kind: 'ellipse';
fill?: boolean;
fill_color?: string;
fill_opacity?: number;
}
export interface DecorText extends DecorBase {
kind: 'text';
x: number;
y: number;
text: string;
/** Canonical physical font size. `size`/`scale` are legacy fallbacks. */
size_cm?: number;
size?: 's' | 'm' | 'l';
scale?: number;
angle?: number;
/** Legacy live-text fields; new writes keep references inside `text`. */
entity?: string | null;
attr?: string | null;
unit?: string | null;
}
export interface DecorFurniture extends DecorBoxBase {
kind: 'furniture';
symbol: string;
}
export type DecorShape = DecorLine | DecorRect | DecorEllipse | DecorText | DecorFurniture;
/** Session default / resolved per-object visual style. */
export interface DecorStyle {
color: string;
opacity: number;
widthCm: number;
fill: boolean;
fillColor: string;
fillOpacity: number;
}
/**
* Future custom pictures implement this contract without changing selection,
* snapping or transform chrome. File upload/lifecycle is intentionally not a
* part of the current release.
*/
export interface DecorImageTransform {
x: number;
y: number;
w: number;
h: number;
angle?: number;
opacity?: number;
}
+1191 -328
View File
File diff suppressed because it is too large Load Diff
+230
View File
@@ -0,0 +1,230 @@
import { LitElement, css, html, nothing } from 'lit';
/**
* Compact colour + opacity picker used by decor defaults and object dialogs.
*
* Only the swatch lives in the parent toolbar/form. The alpha controls are in
* the picker itself, so every consumer gets the same compact layout without
* relying on the still-inconsistent native `input[type=color][alpha]` support.
*/
export class HpColorOpacity extends LitElement {
public label = '';
public opacityLabel = 'Opacity';
public color = '#607d8b';
public opacity = 1;
public disabled = false;
private _open = false;
private _alignEnd = false;
static properties = {
label: { type: String },
opacityLabel: { type: String, attribute: 'opacity-label' },
color: { type: String },
opacity: { type: Number },
disabled: { type: Boolean, reflect: true },
_open: { state: true },
_alignEnd: { state: true },
};
static styles = css`
:host {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.label {
font-size: 12px;
color: var(--secondary-text-color, #9aa4ad);
white-space: nowrap;
}
.trigger {
width: 34px;
height: 30px;
flex: none;
box-sizing: border-box;
padding: 3px;
border: 1px solid var(--divider-color, #666);
border-radius: 7px;
background:
linear-gradient(45deg, #b8b8b8 25%, transparent 25%) 0 0 / 10px 10px,
linear-gradient(45deg, transparent 75%, #b8b8b8 75%) 0 0 / 10px 10px,
linear-gradient(45deg, transparent 75%, #b8b8b8 75%) 5px -5px / 10px 10px,
linear-gradient(45deg, #b8b8b8 25%, #eee 25%) 5px 5px / 10px 10px;
cursor: pointer;
}
.trigger:hover,
.trigger:focus-visible,
.trigger[aria-expanded='true'] {
border-color: var(--primary-color, #03a9f4);
outline: none;
box-shadow: 0 0 0 1px var(--primary-color, #03a9f4);
}
.swatch {
display: block;
width: 100%;
height: 100%;
border-radius: 3px;
box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.18);
pointer-events: none;
}
.picker {
position: absolute;
z-index: 120;
top: calc(100% + 7px);
left: 0;
width: 226px;
box-sizing: border-box;
display: grid;
gap: 10px;
padding: 12px;
color: var(--primary-text-color, #fff);
background: var(--card-background-color, #202126);
border: 1px solid var(--primary-color, #03a9f4);
border-radius: 10px;
box-shadow: 0 10px 28px rgb(0 0 0 / 0.34);
}
.picker.end {
right: 0;
left: auto;
}
.row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.caption {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--secondary-text-color, #9aa4ad);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
input[type='color'] {
width: 76px;
height: 34px;
flex: none;
padding: 2px;
border: 1px solid var(--divider-color, #666);
border-radius: 7px;
background: transparent;
cursor: pointer;
}
input[type='range'] {
width: auto;
min-width: 0;
flex: 1;
accent-color: var(--primary-color, #03a9f4);
}
input[type='number'] {
width: 50px;
flex: none;
box-sizing: border-box;
border: 1px solid var(--divider-color, #666);
border-radius: 6px;
padding: 4px 5px;
color: var(--primary-text-color, #fff);
background: var(--input-fill-color, transparent);
font: inherit;
}
.pct {
color: var(--secondary-text-color, #9aa4ad);
font-size: 12px;
}
:host([disabled]) {
opacity: .5;
pointer-events: none;
}
`;
connectedCallback(): void {
super.connectedCallback();
document.addEventListener('pointerdown', this._outsidePointerDown, true);
this.addEventListener('keydown', this._keyDown, true);
}
disconnectedCallback(): void {
document.removeEventListener('pointerdown', this._outsidePointerDown, true);
this.removeEventListener('keydown', this._keyDown, true);
super.disconnectedCallback();
}
private _outsidePointerDown = (event: PointerEvent): void => {
if (this._open && !event.composedPath().includes(this)) this._open = false;
};
private _keyDown = (event: KeyboardEvent): void => {
if (!this._open || event.key !== 'Escape') return;
event.preventDefault();
event.stopImmediatePropagation();
this._open = false;
this.updateComplete.then(() => this.renderRoot.querySelector<HTMLButtonElement>('.trigger')?.focus());
};
private _toggle(): void {
if (this.disabled) return;
this._open = !this._open;
this._alignEnd = false;
if (!this._open) return;
this.updateComplete.then(() => {
const popup = this.renderRoot.querySelector<HTMLElement>('.picker');
if (popup && popup.getBoundingClientRect().right > window.innerWidth - 8) this._alignEnd = true;
});
}
private _emit(color: string, opacity: number): void {
const value = Number.isFinite(opacity) ? opacity : Number(this.opacity) || 0;
const clamped = Math.min(1, Math.max(0, value));
// Keep the open picker responsive even before its parent has completed the
// reactive round-trip and passed the new pair back as properties.
this.color = color;
this.opacity = clamped;
this.dispatchEvent(new CustomEvent('hp-color-opacity-change', {
detail: { color, opacity: clamped },
bubbles: true,
composed: true,
}));
}
render() {
const pct = Math.round(Math.min(1, Math.max(0, Number(this.opacity) || 0)) * 100);
const title = `${this.label || 'Color'}: ${this.color}, ${pct}%`;
return html`
${this.label ? html`<span class="label">${this.label}</span>` : nothing}
<button class="trigger" type="button" .disabled=${this.disabled}
aria-label=${title} aria-haspopup="dialog" aria-expanded=${this._open ? 'true' : 'false'}
title=${title} @click=${this._toggle}>
<span class="swatch" style=${`background:${this.color};opacity:${pct / 100}`}></span>
</button>
${this._open && !this.disabled ? html`
<div class="picker ${this._alignEnd ? 'end' : ''}" role="dialog" aria-label=${this.label || 'Color'}>
<div class="row">
<span class="caption">${this.label || 'Color'}</span>
<input type="color" .value=${this.color} aria-label=${this.label || 'Color'}
@input=${(e: Event) => this._emit((e.target as HTMLInputElement).value, this.opacity)} />
</div>
<div class="row">
<span class="caption">${this.opacityLabel}</span>
<input type="range" min="0" max="100" step="1" .value=${String(pct)}
aria-label=${this.opacityLabel}
@input=${(e: Event) => this._emit(this.color, Number((e.target as HTMLInputElement).value) / 100)} />
<input type="number" min="0" max="100" step="1" .value=${String(pct)}
aria-label=${`${this.opacityLabel}, %`}
@change=${(e: Event) => this._emit(this.color, Number((e.target as HTMLInputElement).value) / 100)} />
<span class="pct">%</span>
</div>
</div>` : nothing}
`;
}
}
if (!customElements.get('hp-color-opacity')) customElements.define('hp-color-opacity', HpColorOpacity);
declare global {
interface HTMLElementTagNameMap { 'hp-color-opacity': HpColorOpacity; }
}
+14 -1
View File
@@ -62,7 +62,20 @@ export class HpDialog extends LitElement {
}
.footer {
display: contents;
/* ha-dialog lays out its footer slot as a flex row. display: contents
exposed the consumer's action row as a shrink-to-fit flex item, so a
wide device dialog got a half-width divider and its Hide action slid
toward the centre. Keep one full-width slot item in both HA and the
native fallback. */
display: block;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
::slotted([slot='footer']) {
width: 100%;
box-sizing: border-box;
}
dialog {
+22 -9
View File
@@ -9,7 +9,6 @@
"btn.save": "Save",
"btn.close": "Close",
"btn.delete": "Delete",
"btn.remove": "Remove",
"btn.edit": "Edit",
"btn.open_in_ha": "Open in HA",
"btn.reset": "Reset",
@@ -62,6 +61,12 @@
"history.move_opening": "Move door or window opening",
"history.delete_opening": "Delete door or window opening",
"history.delete_room": "Delete room",
"history.decor_add": "Add decor object",
"history.decor_edit": "Edit decor object",
"history.decor_move": "Move decor object",
"history.decor_transform": "Transform decor object",
"history.decor_delete": "Delete decor object",
"history.backdrop_transform": "Transform plan backdrop",
"title.markup_opening": "Doors & windows: click a wall to place, click an opening to edit",
"opening.new": "New opening",
"opening.edit": "Door / window",
@@ -143,7 +148,8 @@
"device.virtual": "virtual device",
"confirm.delete_room": "Delete room \"{name}\"?",
"confirm.merge_rooms": "Merge rooms \"{a}\" and \"{b}\"?",
"confirm.remove_marker": "Remove \"{name}\" from the plan?",
"confirm.remove_marker": "Delete \"{name}\" from the plan? The device will disappear completely and stop contributing to plan data. You can add it again later.",
"confirm.erase_decor": "Erase the {kind} object? You can undo this action from the editor history.",
"confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?",
"toast.pos_save_failed": "Failed to save position: {err}",
"toast.no_entity": "The device has no suitable entity",
@@ -165,7 +171,7 @@
"toast.marker_needs_server": "Device editing is available after the config is moved to the server",
"toast.virtual_name_required": "Enter a name for the virtual device",
"toast.marker_saved": "Device saved",
"toast.marker_removed": "Device removed from the plan",
"toast.marker_removed": "Device deleted from the plan",
"toast.integration_missing": "The House Plan integration is not installed — management unavailable",
"toast.plan_formats": "Supported formats: SVG, PNG, JPG, WebP",
"toast.plan_required": "Upload a floor plan — it is required",
@@ -294,12 +300,16 @@
"decor.ellipse": "Oval",
"decor.text": "Text",
"decor.erase": "Erase",
"decor.erase_confirm_title": "Erase object?",
"decor.color": "Color",
"decor.width": "Line width",
"decor.w_thin": "Thin",
"decor.w_mid": "Medium",
"decor.w_thick": "Thick",
"decor.fill": "Fill",
"decor.fill_color": "Fill color",
"decor.length": "Length",
"decor.size": "Size",
"decor.angle": "Rotation angle, °",
"decor.text_size": "Text size",
"decor.backdrop_properties": "Plan backdrop properties",
"decor.text_title": "Text label",
"decor.object_title": "Edit {kind}",
"decor.text_label": "Text",
@@ -310,10 +320,11 @@
"decor.live_attr_ph": "choose state or attribute",
"decor.live_state": "State",
"decor.backdrop": "Backdrop image",
"decor.backdrop_hint": "Drag the picture to move it, pull a corner to resize it. Shift — off the grid.",
"decor.backdrop_hint": "Drag to move; pull a corner to resize; use the top handle to rotate. Shift changes proportions or frees the angle.",
"decor.backdrop_reset": "Reset the picture",
"decor.backdrop_reset_done": "The picture is back at its original place and size",
"marker.icon_auto": "Auto: {icon} (by icon rules; pick one to override)",
"marker.icon_pin_auto": "Pin",
"mode.plan_tip": "Plan editor — the geometry of the home: draw and split/merge rooms, bind them to HA areas, place doors and windows, move room cards, set the scale",
"mode.devices_tip": "Device editor — everything about icons: drag to position, click to edit binding/icon/display, add virtual devices, icon rules",
"mode.decor_tip": "Background editor — purely visual decor under the plan: lines, rectangles, ovals and text labels that never react to clicks",
@@ -326,8 +337,8 @@
"gs.glow_radius": "Glow radius",
"gs.unit_m": "m",
"gs.unit_ft": "ft",
"marker.controls_label": "Controls light sources",
"marker.controls_hint": "With tap action “Toggle”, a click flips all bound lights at once (any on → all off). The icon mirrors their state.",
"marker.controls_label": "Controls other light sources",
"marker.controls_hint": "With tap action “Toggle”, a click flips every source added here. The marker’s own bound entity is controlled directly and cannot be added; use the switch below to classify it as a light.",
"marker.controls_filter": "Search lights and switches…",
"info.controls": "Controls",
"marker.glow_radius_label": "Glow radius (light-sources fill)",
@@ -407,6 +418,7 @@
"marker.hide_tip": "The device will disappear from the plan after saving but will still count toward the room signal. Restore it through \"Show hidden\" in the device editor.",
"marker.show": "Show",
"marker.show_tip": "The device will appear on the plan again after saving.",
"marker.delete_tip": "Completely delete the device from the plan and every aggregate. You can add it again later.",
"tap.run": "Run automation/script/scene",
"tap.cover": "Open/close (curtains/blinds)",
"marker.run_target_label": "What to run",
@@ -496,6 +508,7 @@
"gs.optimize_undone": "The last optimization was undone",
"decor.furniture": "Furniture",
"furn.title": "Furniture library",
"furn.symbol": "Symbol",
"furn.group_furniture": "Furniture",
"furn.group_appliance": "Appliances",
"furn.group_sanitary": "Plumbing",
+23 -10
View File
@@ -9,7 +9,6 @@
"btn.save": "Сохранить",
"btn.close": "Закрыть",
"btn.delete": "Удалить",
"btn.remove": "Убрать",
"btn.edit": "Редактировать",
"btn.open_in_ha": "Открыть в HA",
"btn.reset": "Сброс",
@@ -62,6 +61,12 @@
"history.move_opening": "Перемещение дверного или оконного проёма",
"history.delete_opening": "Удаление дверного или оконного проёма",
"history.delete_room": "Удаление комнаты",
"history.decor_add": "Добавление объекта декора",
"history.decor_edit": "Изменение объекта декора",
"history.decor_move": "Перемещение объекта декора",
"history.decor_transform": "Трансформация объекта декора",
"history.decor_delete": "Удаление объекта декора",
"history.backdrop_transform": "Трансформация подложки плана",
"title.markup_opening": "Двери и окна: клик по стене — добавить, клик по проёму — редактировать",
"opening.new": "Новый проём",
"opening.edit": "Дверь / окно",
@@ -143,7 +148,8 @@
"device.virtual": "виртуальное устройство",
"confirm.delete_room": "Удалить комнату «{name}»?",
"confirm.merge_rooms": "Слить комнаты «{a}» и «{b}»?",
"confirm.remove_marker": "Убрать «{name}» с плана?",
"confirm.remove_marker": "Удалить «{name}» с плана? Устройство исчезнет полностью и перестанет участвовать в данных плана. Позже его можно будет добавить заново.",
"confirm.erase_decor": "Стереть объект «{kind}»? Действие можно отменить из истории редактора.",
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
"toast.pos_save_failed": "Не удалось сохранить позицию: {err}",
"toast.no_entity": "У устройства нет подходящей сущности",
@@ -165,7 +171,7 @@
"toast.marker_needs_server": "Редактирование устройств доступно после переноса конфига на сервер",
"toast.virtual_name_required": "Укажите имя виртуального устройства",
"toast.marker_saved": "Устройство сохранено",
"toast.marker_removed": "Устройство убрано с плана",
"toast.marker_removed": "Устройство удалено с плана",
"toast.integration_missing": "Интеграция House Plan не установлена — управление недоступно",
"toast.plan_formats": "Поддерживаются SVG, PNG, JPG, WebP",
"toast.plan_required": "Загрузите подложку — план этажа обязателен",
@@ -294,12 +300,16 @@
"decor.ellipse": "Овал",
"decor.text": "Надпись",
"decor.erase": "Стереть",
"decor.erase_confirm_title": "Стереть объект?",
"decor.color": "Цвет",
"decor.width": "Толщина линии",
"decor.w_thin": "Тонкая",
"decor.w_mid": "Средняя",
"decor.w_thick": "Толстая",
"decor.fill": "Залить",
"decor.fill": "Заливка",
"decor.fill_color": "Цвет заливки",
"decor.length": "Длина",
"decor.size": "Размер",
"decor.angle": "Угол поворота, °",
"decor.text_size": "Размер текста",
"decor.backdrop_properties": "Свойства картинки-подложки",
"decor.text_title": "Надпись",
"decor.object_title": "Редактирование: {kind}",
"decor.text_label": "Текст",
@@ -310,10 +320,11 @@
"decor.live_attr_ph": "выберите состояние или атрибут",
"decor.live_state": "Состояние",
"decor.backdrop": "Картинка-подложка",
"decor.backdrop_hint": "Тяните картинку — двигается, тяните угол — меняет размер. Shift — мимо сетки.",
"decor.backdrop_hint": "Тяните картинку — перемещение; угол — размер; верхнюю ручку — поворот. Shift меняет пропорции или снимает шаг угла.",
"decor.backdrop_reset": "Вернуть картинку",
"decor.backdrop_reset_done": "Картинка вернулась на своё место и в свой размер",
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
"marker.icon_pin_auto": "Закрепить",
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
"mode.decor_tip": "Редактор подложки — чисто визуальный декор под планом: линии, прямоугольники, овалы и надписи, не реагирующие на клики",
@@ -326,8 +337,8 @@
"gs.glow_radius": "Радиус свечения",
"gs.unit_m": "м",
"gs.unit_ft": "фут",
"marker.controls_label": "Управляет источниками света",
"marker.controls_hint": "При действии «Переключить» клик разом переключает все привязанные источники (горит хоть один → выключить все). Значок отражает их состояние.",
"marker.controls_label": "Управляет другими источниками света",
"marker.controls_hint": "При действии «Переключить» клик разом переключает все добавленные здесь источники. Собственная сущность маркера управляется напрямую и сюда не добавляется; чтобы считать её светом, включите флаг ниже.",
"marker.controls_filter": "Поиск ламп и выключателей…",
"info.controls": "Управляет",
"marker.glow_radius_label": "Радиус свечения (заливка «Свет по источникам»)",
@@ -407,6 +418,7 @@
"marker.hide_tip": "Устройство исчезнет с плана после сохранения, но продолжит участвовать в расчёте сигнала комнаты. Вернуть его можно через кнопку «Показать скрытые» в редакторе устройств.",
"marker.show": "Показать",
"marker.show_tip": "Устройство снова появится на плане после сохранения.",
"marker.delete_tip": "Полностью удалить устройство с плана и из всех расчётов. Позже его можно добавить заново.",
"tap.run": "Запустить автоматизацию/скрипт/сцену",
"tap.cover": "Открыть/закрыть (шторы/жалюзи)",
"marker.run_target_label": "Что запускать",
@@ -496,6 +508,7 @@
"gs.optimize_undone": "Последняя оптимизация отменена",
"decor.furniture": "Мебель",
"furn.title": "Библиотека мебели",
"furn.symbol": "Символ",
"furn.group_furniture": "Мебель",
"furn.group_appliance": "Техника",
"furn.group_sanitary": "Сантехника",
+18 -6
View File
@@ -1118,19 +1118,31 @@ export function liveTextValue(hass: any, link: LiveTextLink | null | undefined):
* only when the text has no new references, keeping old saved labels working
* until the user edits and saves them in the new UI.
*/
export function liveText(text: string | null | undefined, link: LiveTextLink | null | undefined, hass: any): string {
export function liveText(
text: string | null | undefined,
link: LiveTextLink | null | undefined,
hass: any,
entityAvailable: (entityId: string) => boolean = () => true,
): string {
const tpl = text ?? '';
let referenced = false;
const rendered = tpl.replace(/\{([^{}\r\n]+)\}/g, (whole, raw: string) => {
const ref = liveTextReference(raw);
if (!ref) return whole;
referenced = true;
if (!entityAvailable(ref.entity || '')) return LIVE_TEXT_DASH;
return liveTextValue(hass, ref);
});
if (referenced) return rendered;
// Backward compatibility for shapes saved by beta.9 and earlier.
if (!(link?.entity || '').trim()) return tpl;
const linkedEntity = (link?.entity || '').trim();
if (!linkedEntity) return tpl;
if (!entityAvailable(linkedEntity)) {
const i = tpl.indexOf(LIVE_TEXT_SLOT);
if (i >= 0) return tpl.slice(0, i) + LIVE_TEXT_DASH + tpl.slice(i + LIVE_TEXT_SLOT.length);
return tpl ? `${tpl} ${LIVE_TEXT_DASH}` : LIVE_TEXT_DASH;
}
const v = liveTextValue(hass, link);
const i = tpl.indexOf(LIVE_TEXT_SLOT);
if (i >= 0) return tpl.slice(0, i) + v + tpl.slice(i + LIVE_TEXT_SLOT.length);
@@ -1142,12 +1154,12 @@ export const DECOR_TEXT_SCALE_MIN = 0.15;
export const DECOR_TEXT_SCALE_MAX = 20;
/**
* The font multiplier of a decor text shape. `scale` is the field the corner
* handles write; the legacy `size` ('s'|'m'|'l') is read as the multiplier it
* Read the legacy font multiplier of a decor text shape. Canonical new writes
* use physical `size_cm`; this helper keeps old `scale` and `size`
* ('s'|'m'|'l') visually stable. `size` is read as the multiplier it
* used to render at (14/20/30 px against the base 20), so a label drawn
* before the handles existed comes back at exactly its old size without any
* migration. An explicit `scale` wins — it is the newer, finer statement of
* the same thing, and the first drag replaces `size` with it.
* migration. An explicit `scale` wins among the legacy representations.
*/
export function decorTextScale(shape: { scale?: unknown; size?: unknown } | null | undefined): number {
const s = Number(shape?.scale);
+76 -9
View File
@@ -9,20 +9,29 @@
*/
import { alignAllToGrid, type AlignReport } from './align-grid';
import { decorTextScale, liveTextReference, liveTextToken, roomPoly } from './logic';
import { effectiveMarkerControls } from './devices';
import {
DECOR_TEXT_BASE, decorTextScale, liveTextReference, liveTextToken, roomPoly,
} from './logic';
import {
clipOpenSpansToShared, cutsToSpanEntries, entryToSeg,
projectOnSeg, rekeyOpenSpansAfterMove, resolveOpenCuts, sanitizeOpenSpans,
snapOpenPoint, spanToEntry, syncOpenToFromCuts,
} from './open-spans';
import { GRID_PITCH, GRID_STEP_N, NORM_W, spaceModels } from './space-geometry';
import {
GRID_PITCH, GRID_STEP_N, NORM_W, PLAN_SCALE_MAX, PLAN_SCALE_MIN, spaceModels,
} from './space-geometry';
import {
degradeWalls, normalizeWallIntervals, rekeyWallsAfterMove,
} from './wall-thickness';
/** Bump when a new lossless maintenance pass is added. */
export const PLAN_MODEL_VERSION = 1;
export const PLAN_MODEL_VERSION = 3;
const DEFAULT_CELL_CM = 5;
const DECOR_WIDTH_CM_MIN = 0.1;
const DECOR_WIDTH_CM_MAX = 100;
const DECOR_TEXT_CM_MIN = 0.1;
const DECOR_TEXT_CM_MAX = 2000;
export interface OptimizeReport extends AlignReport {
modelFrom: number;
@@ -46,6 +55,9 @@ export interface OptimizeResult {
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
const own = (o: any, key: string): boolean => Object.prototype.hasOwnProperty.call(o, key);
const clamp = (value: number, min: number, max: number): number => (
Math.min(max, Math.max(min, value))
);
const modelOf = (space: any): any => (
spaceModels({ spaces: [space], markers: [], settings: {} } as any)[0]
@@ -73,6 +85,13 @@ const migrateLosslessly = (config: any): number => {
let n = 0;
for (const marker of config.markers || []) {
if (marker.display === 'ripple') { marker.display = 'icon_ripple'; n++; }
if (Array.isArray(marker.controls)) {
const controls = effectiveMarkerControls(marker.binding, marker.controls);
if (JSON.stringify(controls) !== JSON.stringify(marker.controls)) {
marker.controls = controls.length ? controls : null;
n++;
}
}
const vacuum = marker.vacuum;
if (vacuum && own(vacuum, 'trail')) {
if (!['never', 'cleaning', 'always'].includes(vacuum.trail_mode)) {
@@ -85,12 +104,52 @@ const migrateLosslessly = (config: any): number => {
for (const space of config.spaces || []) {
if (own(space, 'segments')) { delete space.segments; n++; }
if (own(space, 'plan_scale')) {
const k = Number(space.plan_scale);
if (Number.isFinite(k) && k >= PLAN_SCALE_MIN && k <= PLAN_SCALE_MAX) {
if (!own(space, 'plan_scale_x')) space.plan_scale_x = k;
if (!own(space, 'plan_scale_y')) space.plan_scale_y = k;
delete space.plan_scale;
n++;
}
}
const rawCellCm = Number(space.cell_cm);
const cellCm = Number.isFinite(rawCellCm) && rawCellCm > 0
? rawCellCm : DEFAULT_CELL_CM;
for (const shape of space.decor || []) {
if (own(shape, 'width')) {
const legacyWidth = Number(shape.width);
if (own(shape, 'width_cm') || Number.isFinite(legacyWidth)) {
if (!own(shape, 'width_cm'))
shape.width_cm = Number(clamp(
(legacyWidth / GRID_PITCH) * cellCm,
DECOR_WIDTH_CM_MIN,
DECOR_WIDTH_CM_MAX,
).toFixed(6));
delete shape.width;
n++;
}
}
if ((shape?.kind === 'rect' || shape?.kind === 'ellipse') && shape.fill === true) {
if (!own(shape, 'fill_color')) { shape.fill_color = shape.color || '#607d8b'; n++; }
if (!own(shape, 'fill_opacity')) { shape.fill_opacity = 0.25; n++; }
}
if (shape?.kind !== 'text') continue;
// Old s/m/l text sizes have an exact numerical equivalent.
if (shape.scale === undefined && shape.size !== undefined) {
shape.scale = decorTextScale(shape);
// Text size is physical styling, like stroke thickness. Convert either
// legacy representation to centimetres without changing its current
// appearance at this space's scale.
if (shape.size_cm === undefined) {
shape.size_cm = Number(clamp(
((DECOR_TEXT_BASE * decorTextScale(shape)) / GRID_PITCH) * cellCm,
DECOR_TEXT_CM_MIN,
DECOR_TEXT_CM_MAX,
).toFixed(6));
delete shape.scale;
delete shape.size;
n++;
} else if (own(shape, 'scale') || own(shape, 'size')) {
delete shape.scale;
delete shape.size;
n++;
}
@@ -263,20 +322,28 @@ export function optimizePlans(configIn: any, layoutIn: Record<string, any>): Opt
if (canonicalAfter !== canonicalBefore) canonicalized++;
}
if (modelFrom !== PLAN_MODEL_VERSION) {
// A version marker is bookkeeping, not maintenance by itself. Persist it
// only alongside a real config/layout transformation; otherwise an already
// canonical plan would forever offer an Optimize action that changes no
// user data. Never downgrade a model written by a newer client.
const meaningfulChanged = JSON.stringify(config) !== original
|| JSON.stringify(aligned.layout) !== originalLayout;
if (modelFrom < PLAN_MODEL_VERSION && meaningfulChanged) {
config.model_version = PLAN_MODEL_VERSION;
migrated++;
}
const changed = JSON.stringify(config) !== original
|| JSON.stringify(aligned.layout) !== originalLayout;
const modelTo = Number.isInteger(Number(config.model_version))
? Number(config.model_version)
: modelFrom;
return {
config,
layout: aligned.layout,
report: {
...alignReport,
modelFrom,
modelTo: PLAN_MODEL_VERSION,
modelTo,
migrated,
canonicalized,
wallsMerged,
+1 -1
View File
@@ -31,7 +31,7 @@ export const DEFAULT_ICON_RULES: IconRule[] = [
{ pattern: 'термоголов|trv|radiator', icon: 'mdi:radiator' },
{ pattern: 'чайник|kettle|термопот', icon: 'mdi:kettle' },
{ pattern: 'сауна|sauna|harvia|парная|парилк', icon: 'mdi:hot-tub' },
{ pattern: 'температ|temperature|climate sensor', icon: 'mdi:thermometer' },
{ pattern: 'температ|temperature|thermometer|climate sensor', icon: 'mdi:thermometer' },
{ pattern: 'qingping|air monitor|молекул|air quality', icon: 'mdi:air-filter' },
{ pattern: 'штор|curtain|blind|shade', icon: 'mdi:roller-shade' },
{ pattern: 'розетк|plug|socket|outlet', icon: 'mdi:power-socket-de' },
+27 -12
View File
@@ -5,6 +5,7 @@
*/
import { declump, contentUrl } from './logic';
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
import { boxCorners, normalizeAngle } from './editors/decor/geometry';
export const NORM_W = 1000; // side of the render space — the canvas is square
@@ -24,7 +25,7 @@ export function fitInSquare(ratio: number | null | undefined, side: number) {
return { x: (side - w) / 2, y: (side - h) / 2, w, h };
}
/** Uniform backdrop scale bounds — mirrors validation.py (docs/BACKDROP.md). */
/** Per-axis backdrop scale bounds — mirrors validation.py (docs/BACKDROP.md). */
export const PLAN_SCALE_MIN = 0.01;
export const PLAN_SCALE_MAX = 100;
@@ -32,33 +33,43 @@ export const PLAN_SCALE_MAX = 100;
* WHERE THE BACKDROP IMAGE SITS (docs/BACKDROP.md).
*
* `fitInSquare` is only the DEFAULT placement: the image centred in the square
* canvas at its own proportions. On top of it a space may carry three optional
* numbers, the ones the owner produces by dragging the transform frame in the
* backdrop editor:
* canvas at its own proportions. On top of it a space may carry an optional
* transform produced by the Background editor:
*
* plan_x, plan_y — offset of the image's top-left corner from that default,
* in NORMALISED units (the very units rooms, openings and
* decor use, bounded by ±CANVAS_LIMIT);
* plan_scale — ONE uniform multiplier for both sides, anchored at the
* (already offset) top-left corner. There is no rotation.
* plan_scale — legacy uniform fallback for both sides;
* plan_scale_x/y — canonical independent multipliers;
* plan_angle — rotation around the transformed rectangle centre.
*
* All three are OPTIONAL and their absence is exactly the pre-v1.58.0
* All transform fields are OPTIONAL and their absence is exactly the pre-v1.58.0
* behaviour, so every plan written before renders bit-identically and there is
* no migration to run.
*/
export function planRect(space: any, side = NORM_W): Rect {
export type PlanRect = Rect & { angle?: number };
export function planRect(space: any, side = NORM_W): PlanRect {
const base = fitInSquare(space?.plan_aspect, side);
const raw = Number(space?.plan_scale);
const k = Number.isFinite(raw) && raw > 0
? Math.min(PLAN_SCALE_MAX, Math.max(PLAN_SCALE_MIN, raw))
: 1;
const kxRaw = Number(space?.plan_scale_x);
const kyRaw = Number(space?.plan_scale_y);
const kx = Number.isFinite(kxRaw) && kxRaw > 0
? Math.min(PLAN_SCALE_MAX, Math.max(PLAN_SCALE_MIN, kxRaw)) : k;
const ky = Number.isFinite(kyRaw) && kyRaw > 0
? Math.min(PLAN_SCALE_MAX, Math.max(PLAN_SCALE_MIN, kyRaw)) : k;
const dx = Number(space?.plan_x);
const dy = Number(space?.plan_y);
const angle = normalizeAngle(space?.plan_angle);
return {
x: base.x + (Number.isFinite(dx) ? clampCanvasN(dx) : 0) * side,
y: base.y + (Number.isFinite(dy) ? clampCanvasN(dy) : 0) * side,
w: base.w * k,
h: base.h * k,
w: base.w * kx,
h: base.h * ky,
...(angle ? { angle } : {}),
};
}
@@ -117,7 +128,7 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
title: s.title,
vb: [vb[0] * NORM_W, vb[1] * H, vb[2] * NORM_W, vb[3] * H],
// the image's own placement — the centred default plus whatever the
// backdrop frame has stored (plan_x/plan_y/plan_scale, docs/BACKDROP.md)
// backdrop frame has stored (plan_x/y, per-axis scale and angle)
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...planRect(s, NORM_W) } : null,
rooms: (s.rooms || []).map(scale),
} as SpaceModel;
@@ -225,7 +236,11 @@ export function contentItems(
// of the picture nobody has drawn over yet, and — since v1.58.0 — the
// rectangle here is the MOVED and SCALED one, so «Вписать всё» follows the
// picture wherever the owner has dragged it.
if (space.bg) out.push({ minX: space.bg.x, minY: space.bg.y, maxX: space.bg.x + space.bg.w, maxY: space.bg.y + space.bg.h });
if (space.bg) {
const pts = boxCorners(space.bg);
const item = itemOf(pts);
if (item) out.push(item);
}
for (const e of extra || []) {
if (Array.isArray(e)) { const it = itemOf([e as any]); if (it) out.push(it); }
else out.push(e as ContentItem);
+10 -3
View File
@@ -235,14 +235,21 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}"></rect>`,
)}
${bgHref
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}"
transform=${space.bg!.angle
? `rotate(${space.bg!.angle} ${space.bg!.x + space.bg!.w / 2} ${space.bg!.y + space.bg!.h / 2})`
: nothing}
preserveAspectRatio="none" />`
: nothing}
${roomShapes}
${wallUnion
? svg`<g class="wallbodies" style="--room-stroke:${disp.color}">
<path class="wallbody-fill" d="${wallUnion.d}"></path>
<path class="wallbody-fill" d="${wallUnion.d}"
fill="${colors.wall_fill.c}" fill-opacity="${colors.wall_fill.a}" fill-rule="evenodd"
stroke="none" pointer-events="none"></path>
<path class="wallbody ${solidWall ? 'solid' : ''}" data-hp="wall" data-id="union" data-kind="union"
d="${wallUnion.d}"></path>
d="${wallUnion.d}" fill="${solidWall ? 'none' : 'url(#hp-wall-hatch)'}" fill-rule="evenodd"
stroke="${disp.color || 'var(--hp-muted, #607d8b)'}" stroke-width="0.6" pointer-events="none"></path>
</g>`
: nothing}
</svg>
+45 -3
View File
@@ -274,6 +274,16 @@ export const cardStyles = css`
.stage.hpboot .zoombadge {
visibility: hidden;
}
/* A long-suspended normal View wakes through transient HA/viewport
geometry. Keep those few frames behind the already-correct stage
background; the final viewport and reveal are committed together.
This class is never set in kiosk or an editor. */
.stage.hpresume .zoomwrap,
.stage.hpresume .zoombadge,
.stage.hpresume .farhint,
.stage.hpresume .homearrow {
visibility: hidden;
}
/* AUD-1552-02: post-veil grace — HA chrome landing after the cap moves
the stage height smoothly; the viewport ResizeObserver refits the plan
along the transition, so a late panel glides instead of jumping. */
@@ -843,6 +853,8 @@ export const cardStyles = css`
margin: var(--sp-2) 0 0;
}
.iconauto ha-icon { --mdc-icon-size: 18px; }
.iconauto span { flex: 1; }
.iconauto .btn { min-height: 32px; padding: 0 var(--sp-3); }
.rlhandle {
display: none;
position: absolute;
@@ -947,7 +959,6 @@ export const cardStyles = css`
filter: drop-shadow(0 0 3px var(--hp-accent));
}
.decorlayer .ddraft {
opacity: 0.75;
stroke-dasharray: 6 5;
pointer-events: none;
}
@@ -1001,7 +1012,7 @@ export const cardStyles = css`
transform: translate(-50%, -50%);
border: 1px solid var(--hp-accent);
}
/* the selected text block's frame — same chrome rules as the backdrop's:
/* the selected decor object's frame — same chrome rules as the backdrop's:
the outline never takes a pointer, the handles always do, and they are
finger-sized because this is dragged on a tablet too */
.dtframe .dtbox {
@@ -1013,7 +1024,8 @@ export const cardStyles = css`
pointer-events: none;
opacity: 0.85;
}
.dtframe .dtstem {
.dtframe .dtstem,
.bdframe .dtstem {
stroke: var(--hp-accent);
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
@@ -1040,6 +1052,8 @@ export const cardStyles = css`
.dtframe .dt-nwse { cursor: nwse-resize; }
.dtframe .dt-nesw { cursor: nesw-resize; }
.dtframe .dtrot { cursor: grab; }
.dtframe .dtendpoint { cursor: crosshair; }
.bdframe .dtrot { cursor: grab; }
.dtarea {
resize: vertical;
min-height: 3.4em;
@@ -1050,6 +1064,10 @@ export const cardStyles = css`
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
cursor: crosshair;
}
.stage.mode-decor.dtool-erase,
.stage.mode-decor.dtool-erase .decorlayer .dshape {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cg transform='rotate(-45 12 12)'%3E%3Crect x='7' y='2' width='10' height='18' rx='2' fill='%23fff' stroke='%23111' stroke-width='1.5'/%3E%3Cpath d='M7 13h10v5a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2z' fill='%23ff9f43' stroke='%23111' stroke-width='1.5'/%3E%3C/g%3E%3C/svg%3E") 5 22, pointer;
}
.stage.mode-decor .room, .stage.mode-decor .devlayer { pointer-events: none; }
.stage.mode-decor .oplock { pointer-events: none; }
/* decor mode: everything but the decor itself fades back */
@@ -1077,6 +1095,14 @@ export const cardStyles = css`
.decorbar .dfill {
display: inline-flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-s); cursor: pointer;
}
.decorbar .dfill input[type="checkbox"] {
width: 16px;
height: 16px;
flex: none;
margin: 0;
padding: 0;
}
.decorbar hp-color-opacity { flex: 0 0 auto; }
hp-dialog .dfill {
display: inline-flex;
align-items: center;
@@ -2317,6 +2343,22 @@ export const cardStyles = css`
align-items: center;
flex-wrap: wrap;
}
/* Device info can have Edit + Open in HA + Close. A small HA dialog is
narrower than those three Russian-labelled actions; without wrapping,
HA's end-aligned footer pushed the first button outside the surface. */
hp-dialog .row.infofooter {
align-items: center;
flex-wrap: wrap;
gap: var(--sp-3);
}
hp-dialog .row.infofooter .btn {
flex-shrink: 0;
}
@media (max-width: 480px) {
hp-dialog .row.infofooter {
padding: var(--sp-4) var(--sp-5);
}
}
.markeractions,
.markersaveactions {
display: flex;
+7 -1
View File
@@ -37,7 +37,7 @@ export interface SpaceModel {
id: string;
title: string;
vb: number[]; // render units
bg: { href: string; x: number; y: number; w: number; h: number } | null;
bg: { href: string; x: number; y: number; w: number; h: number; angle?: number } | null;
rooms: RoomCfg[]; // render units
}
@@ -53,6 +53,9 @@ export interface Marker {
space?: string | null;
area?: string | null;
hidden?: boolean;
/** Tombstone: excluded from every plan renderer/aggregate, but its binding
* remains available in the Add-device picker. */
removed?: boolean;
name?: string | null;
icon?: string | null;
model?: string | null;
@@ -144,6 +147,9 @@ export interface DevItem {
hum?: number | null;
virtual?: boolean;
marker?: Marker; // linked config marker (metadata, overrides)
/** Runtime-effective controls. The marker retains the user's complete
* persisted list so transient tombstones cannot erase it on the next save. */
controls?: string[];
bindingKind?: 'device' | 'entity' | 'virtual';
bindingRef?: string; // device_id / entity_id
link?: string | null;
+117
View File
@@ -0,0 +1,117 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
boxCorners, decorStrokeUnits, decorStyleOf, decorStylePatch, normalizeAngle,
resizeDecorBox, resizedBoxTopLeft, snapDecorPoint, validDecorDraft,
} from '../test-build/editors/decor/geometry.js';
const close = (actual, expected, epsilon = 1e-9) =>
assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
test('canonical physical stroke and legacy render stroke remain visually exact', () => {
close(decorStrokeUnits({ width_cm: 5 }, 5, 12), 12);
close(decorStrokeUnits({ width: 3 }, 5, 12), 3);
const legacy = decorStyleOf({ id: 'r', kind: 'rect', x: 0, y: 0, w: 1, h: 1,
color: '#123456', width: 3, fill: true }, 5, 12);
close(legacy.widthCm, 1.25);
assert.equal(legacy.fillColor, '#123456');
close(legacy.fillOpacity, 0.25);
});
test('decor style fallbacks and canonical patch stay inside the persisted schema', () => {
const fallback = {
color: '#112233', opacity: 0.8, widthCm: 4,
fill: false, fillColor: '#445566', fillOpacity: 0.3,
};
const style = decorStyleOf({ id: 'r', kind: 'rect', x: 0, y: 0, w: 1, h: 1,
color: 'invalid', opacity: 8, fill: true, fill_color: 'invalid' }, 5, 12, fallback);
assert.equal(style.color, fallback.color);
assert.equal(style.opacity, 1);
assert.equal(style.fillColor, fallback.color);
assert.equal(style.fillOpacity, 0.25);
const high = decorStylePatch({ ...fallback, widthCm: 1e6, opacity: -2,
fill: true, fillOpacity: 4 }, true);
assert.equal(high.width_cm, 100);
assert.equal(high.opacity, 0);
assert.equal(high.fill_opacity, 1);
const lowLine = decorStylePatch({ ...fallback, widthCm: -1 }, false);
assert.equal(lowLine.width_cm, 0.1);
assert.equal('fill' in lowLine, false);
});
test('angle normalization is stable at wrap boundaries', () => {
assert.equal(normalizeAngle(0), 0);
assert.equal(normalizeAngle(180), 180);
assert.equal(normalizeAngle(181), -179);
assert.equal(normalizeAngle(-181), 179);
assert.equal(normalizeAngle(360), 0);
assert.equal(normalizeAngle(Number.POSITIVE_INFINITY), 0);
});
test('oriented resize preserves ratio by default and Shift-style mode separates axes', () => {
const original = { x: 10, y: 20, w: 40, h: 20, angle: 30 };
const proportional = resizeDecorBox(original, 1, 1, 100, 100, true, 5, 5);
close(proportional.w / proportional.h, 2);
assert.equal(proportional.w % 5, 0);
assert.equal(proportional.h % 5, 0);
const independent = resizeDecorBox(original, 1, 1, 100, 100, false, 5, 5);
assert.notEqual(independent.w / independent.h, 2);
assert.equal(independent.w % 5, 0);
assert.equal(independent.h % 5, 0);
});
test('decor magnet may choose a point or edge but always returns a grid point', () => {
const grid = ([x, y]) => [Math.round(x / 10) * 10, Math.round(y / 10) * 10];
const geometry = { points: [[31, 39]], segments: [{ a: [0, 50], b: [100, 50] }] };
const point = snapDecorPoint([32, 38], geometry, 8, grid);
assert.equal(point.kind, 'point');
assert.deepEqual(point.point, [30, 40]);
const edge = snapDecorPoint([63, 47], geometry, 8, grid);
assert.equal(edge.kind, 'edge');
assert.deepEqual(edge.point, [60, 50]);
const empty = snapDecorPoint([14, 26], { points: [], segments: [] }, 8, grid);
assert.deepEqual(empty, { point: [10, 30], target: null, kind: 'grid' });
const tie = snapDecorPoint([20, 20], {
points: [[20, 30]], segments: [{ a: [10, 10], b: [30, 10] }],
}, 10, grid);
assert.equal(tie.kind, 'point', 'equal-distance point wins deterministically');
});
test('rotated box exposes four rotated content corners around the same centre', () => {
const corners = boxCorners({ x: 0, y: 0, w: 20, h: 10, angle: 90 });
assert.deepEqual(corners.map((p) => p.map((v) => Math.round(v))), [[15, -5], [15, 15], [5, 15], [5, -5]]);
});
test('oriented resize caller preserves the derived top-left instead of translating the fixed corner', () => {
const original = { x: 10, y: 20, w: 40, h: 20, angle: 30 };
const resized = resizeDecorBox(original, 1, 1, 97, 83, true, 5, 5);
const fixedBefore = boxCorners(original)[0];
const fixedAfter = boxCorners(resized)[0];
close(fixedAfter[0], fixedBefore[0]);
close(fixedAfter[1], fixedBefore[1]);
const grid = ([x, y]) => [Math.round(x / 5) * 5, Math.round(y / 5) * 5];
assert.deepEqual(resizedBoxTopLeft(resized, original.angle, grid), [resized.x, resized.y]);
assert.deepEqual(resizedBoxTopLeft({ x: 12.2, y: 18.1 }, 0, grid), [10, 20]);
});
test('resize cannot flip across the fixed corner and honours the common grid scale', () => {
const original = { x: 0, y: 0, w: 40, h: 20, angle: 0 };
const common = resizeDecorBox(original, 1, 1, 51, 31, true, 10, 10);
assert.deepEqual([common.w, common.h], [60, 30]);
assert.equal(common.w % 10, 0);
assert.equal(common.h % 10, 0);
const crossed = resizeDecorBox(original, 1, 1, -100, -100, false, 10, 10);
assert.deepEqual([crossed.w, crossed.h], [10, 10]);
assert.deepEqual(boxCorners(crossed)[0], boxCorners(original)[0],
'opposite corner stays fixed at the minimum-size floor');
});
test('flat rectangle and ellipse drafts are rejected while a straight line is valid', () => {
assert.equal(validDecorDraft('line', [0, 0], [20, 0], 5), true);
assert.equal(validDecorDraft('rect', [0, 0], [20, 0], 5), false);
assert.equal(validDecorDraft('ellipse', [0, 0], [0, 20], 5), false);
assert.equal(validDecorDraft('rect', [0, 0], [20, 10], 5), true);
});
+34
View File
@@ -31,6 +31,40 @@ test('actual work is yellow/running, but an enabled automation is neutral', () =
assert.equal(entityVisualSample(h, 'script.scene').status, 'working');
});
test('media players express power without treating playback as work', () => {
const h = hass({
'media_player.soundbar': { state: 'playing' },
'media_player.tv': { state: 'on' },
'media_player.receiver': { state: 'idle' },
'media_player.speaker': { state: 'off' },
});
for (const eid of ['media_player.soundbar', 'media_player.tv', 'media_player.receiver']) {
assert.deepEqual(
(({ status, activity }) => ({ status, activity }))(entityVisualSample(h, eid)),
{ status: 'neutral', activity: 'none' },
);
}
assert.deepEqual(
(({ availability, status, activity }) => ({ availability, status, activity }))(
entityVisualSample(h, 'media_player.speaker'),
),
{ availability: 'unavailable', status: 'neutral', activity: 'none' },
);
});
test('off media sources share unavailable styling without hiding an active peer', () => {
const off = entityVisualSample(hass({ 'media_player.one': { state: 'off' } }), 'media_player.one');
const on = entityVisualSample(hass({ 'media_player.two': { state: 'on' } }), 'media_player.two');
assert.deepEqual(
combineVisualSamples([off]),
{ availability: 'unavailable', status: 'neutral', activity: 'none' },
);
assert.deepEqual(
combineVisualSamples([off, on]),
{ availability: 'available', status: 'neutral', activity: 'none' },
);
});
test('open state, cover travel and presence are separate meanings', () => {
const h = hass({
'binary_sensor.window': { state: 'on', attributes: { device_class: 'window' } },
+192 -1
View File
@@ -5,6 +5,7 @@ import {
areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimate, areaClimateMap,
litLightEntity, resolvedDeviceStateEntities, resolvedLightSources, resolvedLightState,
resolvedLightStats, seedHiddenBindings,
deletePlanMarkerRecords, effectiveMarkerControls,
} from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js';
@@ -22,6 +23,30 @@ const baseCtx = (hass, over = {}) => ({
const dev = (id, name, model, area, extra = {}) =>
({ id, name, model, area_id: area, identifiers: [['demo', id]], entry_type: null, via_device_id: null, ...extra });
test('deletePlanMarkerRecords removes only the selected virtual marker', () => {
const markers = [
{ id: 'v_a', binding: 'virtual', name: 'A' },
{ id: 'v_b', binding: 'virtual', name: 'B', pdfs: [{ name: 'manual', url: '/files/v_b/manual.pdf' }] },
{ id: 'plug', binding: 'device:plug' },
];
const result = deletePlanMarkerRecords(markers, 'v_a', 'virtual', true);
assert.deepEqual(result.markers, [markers[1], markers[2]]);
assert.deepEqual([...result.cleanupIds], ['v_a']);
});
test('deletePlanMarkerRecords deduplicates HA binding and leaves old-card-safe tombstone', () => {
const result = deletePlanMarkerRecords([
{ id: 'old', binding: 'entity:sensor.x' },
{ id: 'new', binding: 'entity:sensor.x' },
{ id: 'other', binding: 'entity:sensor.y' },
], 'new', 'entity:sensor.x', false);
assert.deepEqual(result.markers, [
{ id: 'other', binding: 'entity:sensor.y' },
{ id: 'new', binding: 'entity:sensor.x', removed: true, hidden: true },
]);
assert.deepEqual(new Set(result.cleanupIds), new Set(['old', 'new']));
});
test('buildDevices: devices outside bound areas are dropped', () => {
const h = mkHass({ devices: {
a: dev('a', 'Lamp', 'Bulb', 'living'),
@@ -96,6 +121,57 @@ test('buildDevices: hidden marker removes the device entirely', () => {
assert.equal(res.length, 0);
});
test('buildDevices: deleted binding is claimed but never built', () => {
const h = mkHass({ devices: { a: dev('a', 'Deleted', 'Sensor', 'living') } });
const markers = [{ id: 'a', binding: 'device:a', removed: true }];
assert.deepEqual(buildDevices(baseCtx(h, { markers })).map((d) => d.id), []);
assert.deepEqual(
buildDevices(baseCtx(h, { markers, settings: { filter_seeded: true } })).map((d) => d.id),
[],
);
});
test('buildDevices: deleted controls are inactive without being destructively persisted', () => {
const h = mkHass({
entities: {
'light.keep': { entity_id: 'light.keep' },
'light.gone': { entity_id: 'light.gone' },
},
states: {
'light.keep': { state: 'on', attributes: {} },
'light.gone': { state: 'on', attributes: {} },
},
});
const markers = [
{ id: 'v1', binding: 'virtual', name: 'Combined light', controls: ['light.keep', 'light.gone'] },
{ id: 'gone', binding: 'entity:light.gone', removed: true },
];
const item = buildDevices(baseCtx(h, { markers })).find((d) => d.id === 'v1');
assert.deepEqual(item.marker.controls, ['light.keep', 'light.gone']);
assert.deepEqual(item.controls, ['light.keep']);
assert.deepEqual(resolvedLightSources(h, [item]).map((source) => source.eid), ['light.keep']);
});
test('entity tombstone does not strip that entity from a live parent device', () => {
const h = mkHass({
devices: { sensorbox: dev('sensorbox', 'Climate box', 'Sensor', 'living') },
entities: {
'sensor.box_temp': {
entity_id: 'sensor.box_temp', device_id: 'sensorbox',
original_device_class: 'temperature',
},
},
states: {
'sensor.box_temp': { state: '23.5', attributes: { device_class: 'temperature' } },
},
});
const markers = [{ id: 'gone_entity', binding: 'entity:sensor.box_temp', removed: true }];
const parent = buildDevices(baseCtx(h, { markers })).find((d) => d.id === 'sensorbox');
assert.ok(parent);
assert.deepEqual(parent.entities, ['sensor.box_temp']);
assert.equal(parent.primary, 'sensor.box_temp');
});
test('buildDevices: virtual marker lands in its room; entity marker resolves name from state', () => {
const h = mkHass({
entities: { 'sensor.avg': { entity_id: 'sensor.avg', platform: 'min_max', area_id: 'kitchen' } },
@@ -240,6 +316,36 @@ test('primaryEntity: a real cover beats its option switch, but a mixed lamp stay
);
});
test('media role shields a marker from auxiliary light and switch entities', () => {
const hass = mkHass({
entities: {
'light.soundbar_display': { entity_id: 'light.soundbar_display' },
'switch.soundbar_night_mode': { entity_id: 'switch.soundbar_night_mode' },
'media_player.soundbar': { entity_id: 'media_player.soundbar' },
},
states: {
'light.soundbar_display': { state: 'on', attributes: {} },
'switch.soundbar_night_mode': { state: 'on', attributes: {} },
'media_player.soundbar': { state: 'playing', attributes: {} },
},
});
const entities = [
'light.soundbar_display', 'switch.soundbar_night_mode', 'media_player.soundbar',
];
assert.deepEqual(resolvedDeviceStateEntities(hass, entities), ['media_player.soundbar']);
assert.equal(primaryEntity(hass, entities, 'mdi:soundbar'), 'media_player.soundbar');
const soundbar = {
id: 'soundbar', area: 'living', primary: 'media_player.soundbar', entities,
};
assert.deepEqual(resolvedLightSources(hass, [soundbar]), []);
assert.deepEqual(
resolvedLightSources(hass, [{ ...soundbar, marker: { is_light: true } }])
.map((source) => source.eid),
['light.soundbar_display'],
);
});
test('resolvedDeviceStateEntities: passive readings aggregate availability instead of picking the first', () => {
const hass = mkHass({
entities: {
@@ -465,6 +571,36 @@ test('sourceValue: explicit entity and device sources (tier 3)', () => {
assert.equal(sourceValue(hass, 'device:nope', 'temp'), null);
assert.equal(sourceValue(hass, '', 'temp'), null);
assert.equal(sourceValue(hass, 'garbage', 'temp'), null);
assert.equal(
sourceValue(hass, 'entity:sensor.custom_temp', 'temp', [
{ id: 'gone', binding: 'entity:sensor.custom_temp', removed: true },
]),
null,
);
assert.equal(
sourceValue(hass, 'device:dev1', 'temp', [
{ id: 'gone', binding: 'device:dev1', removed: true },
]),
null,
);
});
test('sourceValue: entity tombstone does not remove data from a surviving device source', () => {
const hass = {
devices: { dev1: { id: 'dev1' } },
entities: {
'sensor.gone': { device_id: 'dev1' },
'sensor.keep': { device_id: 'dev1' },
},
states: {
'sensor.keep': { state: '20', attributes: { device_class: 'temperature' } },
'sensor.gone': { state: '30', attributes: { device_class: 'temperature' } },
},
};
const markers = [{ id: 'gone', binding: 'entity:sensor.gone', removed: true }];
// Device sources intentionally use the first valid reading. Keeping the
// tombstoned entity inside its live parent therefore returns 30, not 20.
assert.equal(sourceValue(hass, 'device:dev1', 'temp', markers), 30);
});
test('areaClimate: counts sensors that are NOT on the plan (field report)', () => {
@@ -691,6 +827,28 @@ test('litLightEntity: one truth for "this thing is shining"', () => {
);
});
test('areaClimateMap: whole device tombstone wins; entity tombstone stays binding-scoped', () => {
const hass = {
devices: {
a: { id: 'a', name: 'Room thermometer A', area_id: 'living' },
b: { id: 'b', name: 'Room thermometer B', area_id: 'living' },
},
entities: {
'sensor.a_temperature': { device_id: 'a', platform: 'demo' },
'sensor.b_temperature': { device_id: 'b', platform: 'demo' },
},
states: {
'sensor.a_temperature': { state: '20', attributes: { device_class: 'temperature' } },
'sensor.b_temperature': { state: '30', attributes: { device_class: 'temperature' } },
},
};
const markers = [
{ id: 'a', binding: 'device:a', removed: true },
{ id: 'b-temp', binding: 'entity:sensor.b_temperature', removed: true },
];
assert.deepEqual(areaClimateMap(hass, undefined, markers).get('living'), { temp: 30, hum: null });
});
test('resolvedLightSources: one source set feeds room fill, card, glow and controls', () => {
const hass = { states: {
'light.auto': { state: 'on' },
@@ -739,6 +897,38 @@ test('resolvedLightSources: marker room_id is more precise than a shared HA area
);
});
test('standalone entity cannot list itself as an external light control', () => {
assert.deepEqual(
effectiveMarkerControls('entity:switch.hood', [
'switch.hood', 'light.mirror', 'switch.hood', 'lock.front_door',
]),
['light.mirror'],
);
assert.deepEqual(
effectiveMarkerControls(
'device:hood', ['switch.hood', 'switch.mirror'], ['switch.hood'],
),
['switch.mirror'],
'a device marker also separates its own entities from external targets',
);
const hass = { states: { 'switch.hood': { state: 'on' } } };
const hood = {
id: 'hood', area: 'bathroom', primary: 'switch.hood', entities: ['switch.hood'],
marker: { binding: 'entity:switch.hood', controls: ['switch.hood'] },
};
assert.deepEqual(resolvedLightSources(hass, [hood]), []);
assert.deepEqual(resolvedLightSources(hass, [{
...hood, marker: { binding: 'device:hood', controls: ['switch.hood'] },
}]), [], 'the same self-control is ignored for a bound HA device');
assert.deepEqual(
resolvedLightSources(hass, [{ ...hood, marker: { ...hood.marker, is_light: true } }])
.map(({ eid, via }) => ({ eid, via })),
[{ eid: 'switch.hood', via: 'forced' }],
'the explicit source flag remains the supported way to make this switch a light',
);
});
// ---------- explicit hide-from-plan flags (docs/FILTERING.md) ----------
@@ -757,8 +947,9 @@ test('seedHiddenBindings: non-physical devices in bound areas, unmarked only', (
const marked = { ...ctx, markers: [
{ id: 'x', binding: 'device:bridge', hidden: false },
{ id: 'y', binding: 'device:grp', hidden: true },
{ id: 'z', binding: 'device:scn', removed: true },
] };
assert.deepEqual(seedHiddenBindings(marked), ['device:scn'], 'marked devices are never revisited');
assert.deepEqual(seedHiddenBindings(marked), [], 'marked and deleted devices are never revisited');
});
test('seeded config: hidden is a flag, not an absence', () => {
+9
View File
@@ -1267,6 +1267,15 @@ test('liveText: inline variables resolve every state and attribute in the text',
);
});
test('liveText: unavailable plan bindings render as a dash without changing the template', () => {
const available = (eid) => eid !== 'sensor.tank';
assert.equal(
liveText('Tank {sensor.tank}; climate {climate.hall:current_temperature}', null, hassLive, available),
'Tank —; climate 21.5',
);
assert.equal(liveText('Tank {}', { entity: 'sensor.tank' }, hassLive, available), 'Tank —');
});
test('liveText: hand-written dotted attributes work; invalid braces stay literal', () => {
assert.equal(liveText('{climate.hall.current_temperature}', null, hassLive), '21.5');
assert.equal(liveText('Обычный {текст}', null, hassLive), 'Обычный {текст}');
+95 -1
View File
@@ -17,6 +17,7 @@ test('optimizePlans migrates, aligns and canonicalises idempotently', () => {
const config = {
spaces: [{
id: 'f1', title: 'Floor', view_box: [0, 0, 1, 1], cell_cm: 5,
plan_scale: 0.75,
segments: [[0, 0, 1, 0]],
rooms: [room('a', 0, 0.5, 'b'), room('b', 0.5, 1, 'a')],
walls: [
@@ -26,6 +27,8 @@ test('optimizePlans migrates, aligns and canonicalises idempotently', () => {
decor: [{
id: 't1', kind: 'text', x: S / 3, y: S / 3,
text: 'Now {}', entity: 'sensor.temp', attr: 'state', size: 'm',
}, {
id: 'l1', kind: 'line', x1: 0, y1: 0, x2: 1, y2: 0, width: 3,
}],
}],
markers: [{
@@ -43,7 +46,13 @@ test('optimizePlans migrates, aligns and canonicalises idempotently', () => {
assert.equal(first.config.markers[0].display, 'icon_ripple');
assert.deepEqual(first.config.markers[0].vacuum, { trail_mode: 'never' });
assert.equal(first.config.spaces[0].decor[0].text, 'Now {sensor.temp}');
assert.equal(first.config.spaces[0].decor[0].scale, 1);
assert.equal(first.config.spaces[0].decor[0].size_cm, 24);
assert.equal('scale' in first.config.spaces[0].decor[0], false);
assert.equal(first.config.spaces[0].decor[1].width_cm, 3.6);
assert.equal('width' in first.config.spaces[0].decor[1], false);
assert.equal(first.config.spaces[0].plan_scale_x, 0.75);
assert.equal(first.config.spaces[0].plan_scale_y, 0.75);
assert.equal('plan_scale' in first.config.spaces[0], false);
assert.equal('entity' in first.config.spaces[0].decor[0], false);
assert.equal(first.config.spaces[0].open_spans.length, 1);
assert.equal(first.config.spaces[0].walls.length, 1);
@@ -54,3 +63,88 @@ test('optimizePlans migrates, aligns and canonicalises idempotently', () => {
assert.deepEqual(second.config, first.config);
assert.deepEqual(second.layout, first.layout);
});
test('legacy physical decor fields are clamped to the persisted schema', () => {
const result = optimizePlans({
spaces: [{
id: 'f1', title: 'Floor', view_box: [0, 0, 1, 1], cell_cm: 1000,
rooms: [],
decor: [
{ id: 'wide', kind: 'line', x1: 0, y1: 0, x2: 1, y2: 0, width: 300 },
{ id: 'thin', kind: 'line', x1: 0, y1: 0, x2: 1, y2: 0, width: 0 },
{ id: 'huge', kind: 'text', x: 0, y: 0, text: 'X', scale: 20 },
],
}],
markers: [], settings: {},
}, {});
const [wide, thin, huge] = result.config.spaces[0].decor;
assert.equal(wide.width_cm, 100);
assert.equal(thin.width_cm, 0.1);
assert.equal(huge.size_cm, 2000);
});
test('invalid legacy plan_scale is preserved instead of being counted as migrated', () => {
const result = optimizePlans({
model_version: PLAN_MODEL_VERSION,
spaces: [{
id: 'f1', title: 'Floor', view_box: [0, 0, 1, 1],
plan_scale: 'broken', rooms: [],
}],
markers: [], settings: {},
}, {});
assert.equal(result.config.spaces[0].plan_scale, 'broken');
assert.equal(result.report.migrated, 0);
});
test('model version bookkeeping does not claim a data migration', () => {
const result = optimizePlans({
model_version: PLAN_MODEL_VERSION - 1,
spaces: [], markers: [], settings: {},
}, {});
assert.equal(result.changed, false);
assert.equal(result.config.model_version, PLAN_MODEL_VERSION - 1);
assert.equal(result.report.modelTo, PLAN_MODEL_VERSION - 1);
assert.equal(result.report.migrated, 0);
});
test('optimizer never downgrades a model from a newer client', () => {
const future = PLAN_MODEL_VERSION + 1;
const result = optimizePlans({
model_version: future,
spaces: [], markers: [], settings: {},
}, {});
assert.equal(result.changed, false);
assert.equal(result.config.model_version, future);
assert.equal(result.report.modelTo, future);
});
test('legacy filled shapes receive the canonical fill fields', () => {
const result = optimizePlans({
model_version: PLAN_MODEL_VERSION,
spaces: [{
id: 'f1', title: 'Floor', view_box: [0, 0, 1, 1], rooms: [],
decor: [{
id: 'r1', kind: 'rect', x: 0, y: 0, w: 0.2, h: 0.2,
color: '#123456', fill: true,
}],
}],
markers: [], settings: {},
}, {});
assert.equal(result.config.spaces[0].decor[0].fill_color, '#123456');
assert.equal(result.config.spaces[0].decor[0].fill_opacity, 0.25);
});
test('optimizer removes legacy self-controls but preserves external targets', () => {
const result = optimizePlans({
model_version: PLAN_MODEL_VERSION - 1,
spaces: [],
markers: [{
id: 'hood', binding: 'entity:switch.hood',
controls: ['switch.hood', 'light.mirror'],
}],
settings: {},
}, {});
assert.deepEqual(result.config.markers[0].controls, ['light.mirror']);
assert.equal(result.config.model_version, PLAN_MODEL_VERSION);
assert.equal(result.report.migrated, 1);
});
+105
View File
@@ -48,6 +48,111 @@ async def test_layout_roundtrip(hass: HomeAssistant, hass_ws_client: WebSocketGe
assert set(resp["result"]["layout"]) == {"dev2"}
async def test_deleted_marker_rejects_a_stale_layout_update(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""A late drag from another tab must not resurrect a deleted position."""
await _setup(hass)
client = await hass_ws_client(hass)
cfg = {
"spaces": [],
"markers": [
{"id": "dev1", "binding": "device:dev1", "removed": True},
{"id": "v_live", "binding": "virtual", "name": "Still here"},
{"id": "v_real", "binding": "device:real-device"},
],
"settings": {},
}
await client.send_json_auto_id({
"type": "houseplan/config/set", "config": cfg, "expected_rev": 0,
})
assert (await client.receive_json())["success"]
await client.send_json_auto_id({
"type": "houseplan/layout/update",
"device_id": "dev1",
"pos": {"s": "f1", "x": 0.1, "y": 0.2},
})
ignored = await client.receive_json()
assert ignored["success"] and ignored["result"]["ignored"] == "removed"
await client.send_json_auto_id({
"type": "houseplan/layout/update",
"device_id": "v_deleted",
"pos": {"s": "f1", "x": 0.3, "y": 0.4},
})
ignored_virtual = await client.receive_json()
assert ignored_virtual["success"]
assert ignored_virtual["result"]["ignored"] == "missing_virtual"
# `v_` is only the historical virtual naming convention. An explicit
# non-virtual marker with that prefix must remain positionable.
await client.send_json_auto_id({
"type": "houseplan/layout/update",
"device_id": "v_real",
"pos": {"s": "f1", "x": 0.2, "y": 0.25},
})
real = await client.receive_json()
assert real["success"] and "ignored" not in real["result"]
await client.send_json_auto_id({"type": "houseplan/layout/get"})
assert (await client.receive_json())["result"]["layout"] == {
"v_real": {"s": "f1", "x": 0.2, "y": 0.25},
}
# The compatibility wholesale endpoint applies the same filter.
await client.send_json_auto_id({
"type": "houseplan/layout/set",
"layout": {
"dev1": {"s": "f1", "x": 0.1, "y": 0.2},
"v_deleted": {"s": "f1", "x": 0.3, "y": 0.4},
"v_live": {"s": "f1", "x": 0.4, "y": 0.5},
"v_real": {"s": "f1", "x": 0.45, "y": 0.55},
"rl_r1": {"s": "f1", "x": 0.35, "y": 0.45},
"ordinary_auto_device": {"s": "f1", "x": 0.5, "y": 0.6},
},
"expected_rev": 1,
})
assert (await client.receive_json())["success"]
await client.send_json_auto_id({"type": "houseplan/layout/get"})
assert (await client.receive_json())["result"]["layout"] == {
"v_live": {"s": "f1", "x": 0.4, "y": 0.5},
"v_real": {"s": "f1", "x": 0.45, "y": 0.55},
"rl_r1": {"s": "f1", "x": 0.35, "y": 0.45},
"ordinary_auto_device": {"s": "f1", "x": 0.5, "y": 0.6},
}
async def test_trail_delete_clears_the_server_book(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
await _setup(hass)
recorder = hass.data[DOMAIN]["trail_recorder"]
recorder.book.data["m1"] = {"current": {"points": [[1, 2]]}}
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "houseplan/trail/delete", "marker_id": "m1"})
response = await client.receive_json()
assert response["success"] and response["result"]["removed"] is True
assert "m1" not in recorder.book.data
async def test_trail_delete_rejects_non_admin_without_mutation(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_read_only_access_token: str,
) -> None:
await _setup(hass)
recorder = hass.data[DOMAIN]["trail_recorder"]
recorder.book.data["m1"] = {"current": {"points": [[1, 2]]}}
# The current HA harness authenticates websocket clients by access token;
# older releases accepted a `user=` shortcut which no longer exists.
client = await hass_ws_client(hass, access_token=hass_read_only_access_token)
await client.send_json_auto_id({"type": "houseplan/trail/delete", "marker_id": "m1"})
response = await client.receive_json()
assert not response["success"] and response["error"]["code"] == "unauthorized"
assert "m1" in recorder.book.data
async def test_config_rev_conflict(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None:
await _setup(hass)
client = await hass_ws_client(hass)
+64
View File
@@ -132,6 +132,47 @@ def test_unknown_source_is_noop():
assert not rec._sample("camera.other", 1.0)
def test_trail_book_delete_forgets_current_and_previous_runs():
book = trails.TrailBook({
"m1": {"current": {"points": [[1, 2]]}, "previous": {"points": [[3, 4]]}},
"m2": {"current": {"points": [[5, 6]]}},
})
assert book.delete("m1") is True
assert "m1" not in book.data
assert "m2" in book.data
assert book.delete("m1") is False
def test_missing_trail_delete_does_not_mutate_live_tracking_pairs():
rec, _hass, _states = _rec()
before = {src: list(pairs) for src, pairs in rec.pairs.items()}
unsubscribed = []
rec._unsub_track = lambda: unsubscribed.append(True)
assert _run_isolated(rec.async_delete("m1")) is False
assert rec.pairs == before
assert unsubscribed == []
def test_trail_delete_prunes_pair_and_replaces_subscription():
tracked = []
old_track = trails.async_track_state_change_event
trails.async_track_state_change_event = lambda hass, ents, cb: (
tracked.append(list(ents)) or (lambda: None)
)
try:
rec, _hass, _states = _rec()
rec.pairs["other.source"] = [("m2", "vacuum.other")]
rec.book.data["m1"] = {"current": {"points": [[1, 2]]}}
unsubscribed = []
rec._unsub_track = lambda: unsubscribed.append(True)
assert _run_isolated(rec.async_delete("m1")) is True
assert rec.pairs == {"other.source": [("m2", "vacuum.other")]}
assert unsubscribed == [True]
assert tracked == [["other.source", "vacuum.other"]]
finally:
trails.async_track_state_change_event = old_track
def test_object_style_position_is_read():
# Tasshack in-memory attributes hold a Point OBJECT, not a dict
class Point:
@@ -276,6 +317,29 @@ def test_refresh_builds_pair_lists_and_dedups_subscription():
trails.async_track_state_change_event = old_track
def test_refresh_never_tracks_a_removed_marker_even_with_stale_vacuum_fields():
class CS:
async def async_load(self):
return {"config": {"markers": [{
"id": "m1",
"binding": "entity:vacuum.x50",
"removed": True,
"vacuum": {"source": "camera.map"},
}]}}
class RT:
config_store = CS()
hass = Hass({
"vacuum.x50": S("cleaning", {}),
"camera.map": S("idle", {"vacuum_position": {"x": 1, "y": 2}}),
})
rec = trails.TrailRecorder(hass, RT())
_run_isolated(rec.async_refresh())
assert rec.pairs == {}
assert rec.book.data == {}
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
+45 -4
View File
@@ -136,6 +136,15 @@ def test_space_backdrop_transform_is_optional_and_bounded():
v.SPACE_SCHEMA({**ok, **bad})
def test_space_cell_scale_is_optional_finite_and_bounded():
ok = {"id": "f1", "title": "1", "view_box": [0, 0, 1, 1], "rooms": []}
assert "cell_cm" not in v.SPACE_SCHEMA(ok)
assert v.SPACE_SCHEMA({**ok, "cell_cm": 20})["cell_cm"] == 20.0
for bad in (0, -1, v.CELL_CM_MAX + 1, float("nan"), float("inf")):
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA({**ok, "cell_cm": bad})
def test_marker_schema():
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc"})
v.MARKER_SCHEMA({"id": "m2", "binding": "virtual", "name": "X",
@@ -144,6 +153,13 @@ def test_marker_schema():
v.MARKER_SCHEMA({"binding": "virtual"})
def test_marker_removed_tombstone_is_strictly_boolean():
assert v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc", "removed": True})["removed"] is True
assert v.MARKER_SCHEMA({"id": "m1", "binding": "entity:sensor.x", "removed": False})["removed"] is False
with pytest.raises(vol.Invalid):
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc", "removed": "yes"})
def test_marker_binding_shape_and_ripple_color():
"""audit P3-4: binding is device:/entity:/virtual; ripple_color is #rrggbb."""
v.MARKER_SCHEMA({"id": "m1", "binding": "entity:sensor.temp"})
@@ -166,6 +182,29 @@ def test_decor_rect_extents_must_be_positive():
v.DECOR_SCHEMA({"id": "d", "kind": "ellipse", "x": 0, "y": 0, "w": 0, "h": 1})
def test_decor_physical_style_and_backdrop_transform():
"""New canonical style/transform fields are bounded; legacy fields stay valid."""
shape = {
"id": "d", "kind": "rect", "x": 0, "y": 0, "w": 0.2, "h": 0.1,
"color": "#123456", "opacity": 0.7, "width_cm": 3.5,
"fill": True, "fill_color": "#abcdef", "fill_opacity": 0.25, "angle": 45,
}
assert v.DECOR_SCHEMA(shape)["width_cm"] == 3.5
assert v.DECOR_SCHEMA({**shape, "width": 3})["width"] == 3
for patch in ({"opacity": 1.1}, {"width_cm": 0}, {"fill_opacity": -0.1}):
with pytest.raises(vol.Invalid):
v.DECOR_SCHEMA({**shape, **patch})
space = {
"id": "s", "title": "S", "view_box": [0, 0, 1, 1], "rooms": [],
"plan_scale_x": 1.2, "plan_scale_y": 0.8, "plan_angle": -35,
}
out = v.SPACE_SCHEMA(space)
assert out["plan_scale_x"] == 1.2 and out["plan_angle"] == -35
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA({**space, "plan_scale_x": 0})
def test_space_id_matches_SPACE_ID_RE():
ok = {"id": "f1", "title": "F", "view_box": [0, 0, 1, 1], "rooms": []}
v.SPACE_SCHEMA(ok)
@@ -1225,10 +1264,8 @@ def test_decor_text_live_fields():
def test_decor_text_block_scale_and_angle():
"""The dialog no longer offers a font size — the block is scaled by its
corners and rotated by its handle. Both fields optional and bounded; the
legacy `size` is still accepted (and still bounded) so a label drawn before
the handles renders exactly as it did."""
"""Physical font size is canonical; scale and size remain bounded legacy
fallbacks so a label drawn before the physical field renders unchanged."""
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
txt = {"id": "d", "kind": "text", "x": 0.5, "y": 0.5, "text": "Porch"}
@@ -1236,6 +1273,7 @@ def test_decor_text_block_scale_and_angle():
return v.CONFIG_SCHEMA({"spaces": [{**base, "decor": [{**txt, **extra}]}]})
assert cfg({"scale": 1})["spaces"][0]["decor"][0]["scale"] == 1.0
assert cfg({"size_cm": 24})["spaces"][0]["decor"][0]["size_cm"] == 24.0
assert cfg({"scale": v.DECOR_TEXT_SCALE_MIN})
assert cfg({"scale": v.DECOR_TEXT_SCALE_MAX})
assert cfg({"angle": 0}) and cfg({"angle": -360}) and cfg({"angle": 360})
@@ -1243,6 +1281,9 @@ def test_decor_text_block_scale_and_angle():
for bad in (0, -1, v.DECOR_TEXT_SCALE_MAX + 1, float("nan"), float("inf")):
with pytest.raises(vol.Invalid):
cfg({"scale": bad})
for bad in (0, -1, v.DECOR_TEXT_CM_MAX + 0.1, float("nan"), float("inf")):
with pytest.raises(vol.Invalid):
cfg({"size_cm": bad})
for bad in (361, -361, float("nan")):
with pytest.raises(vol.Invalid):
cfg({"angle": bad})
+2 -1
View File
@@ -21,6 +21,7 @@
"src/plan-optimizer.ts",
"src/furniture.ts",
"src/wall-thickness.ts",
"src/open-spans.ts"
"src/open-spans.ts",
"src/editors/decor/geometry.ts"
]
}