mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6db9eb0a66 | ||
|
|
3028122016 | ||
|
|
29fb9deb43 |
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
|
||||
+7
-6
@@ -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: План дома
|
||||
|
||||

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

|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 ---------
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 — мимо шага
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
+523
-209
File diff suppressed because one or more lines are too long
Vendored
+523
-209
File diff suppressed because one or more lines are too long
+42
-16
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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-режиме у реального источника света жёлтая подложка может быть скрыта:
|
||||
его устойчивым индикатором служит световое пятно. Эффект активности остаётся.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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), 'Обычный {текст}');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user