v1.50.1: the v1.50.0 review (HP-1500-01..03)

- HP-1500-02: the stage budget was the absolute document coordinate, so any
  tall dashboard content before the card was billed as header and the stage
  collapsed to 0px. Measure our own chrome relative to the card plus a
  bounded (<=120px) allowance for what the viewport keeps above us; re-measure
  on window resize, remove the listener in disconnectedCallback.
- HP-1500-03, both layers: contentBounds opens a near-zero axis (< ~an icon)
  up to a 200-unit floor and ignores extra points outside a canvas envelope
  (-25%..125%) for FRAMING purposes only; the server bounds layout coordinates
  to +-4 — any finite float used to pass, and one 1e100 hid the plan from
  every viewer. A thin real room keeps its tight frame; the gate sensor past
  the edge still stretches it.
- HP-1500-01: no automatic double-transform — a correct layout and a stranded
  one are indistinguishable, and guessing wrong corrupts good data. Explicit
  admin command houseplan/geometry/repair: dry_run previews, the backup rides
  the same store write, undo restores, and routine layout writes now preserve
  unrelated store keys instead of eating the backup.

Tests: contentBounds guards (unit), layout coordinate bounds + repair
lifecycle (harness), card-below-content smoke. Inventory: 139 / 49 / 42 / 64.
This commit is contained in:
Matysh
2026-07-29 01:39:10 +03:00
parent 9282c28830
commit a8ce6020f4
19 changed files with 409 additions and 75 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600 SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.50.0" VERSION = "1.50.1"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.50.0" "version": "1.50.1"
} }
+7 -1
View File
@@ -95,8 +95,14 @@ _TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT) _TEXT_OR_NONE = vol.Any(None, _TEXT)
_URL = vol.All(str, vol.Length(max=MAX_URL)) _URL = vol.All(str, vol.Length(max=MAX_URL))
# Positions are normalised to the canvas (0..1). Allow generous slack for an
# icon dragged past an edge, but not arbitrary magnitudes: any finite float
# used to pass, and a single stored 1e100 stretched every client's view of the
# space until the plan was invisible (HP-1500-03).
_COORD = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
POS_SCHEMA = vol.Schema( POS_SCHEMA = vol.Schema(
{vol.Required("x"): _finite, vol.Required("y"): _finite}, {vol.Required("x"): _COORD, vol.Required("y"): _COORD},
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id) extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
) )
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT)) LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
+85 -3
View File
@@ -41,6 +41,7 @@ def async_register(hass: HomeAssistant) -> None:
"""Register the WS commands.""" """Register the WS commands."""
websocket_api.async_register_command(hass, ws_layout_get) websocket_api.async_register_command(hass, ws_layout_get)
websocket_api.async_register_command(hass, ws_layout_set) websocket_api.async_register_command(hass, ws_layout_set)
websocket_api.async_register_command(hass, ws_geometry_repair)
websocket_api.async_register_command(hass, ws_layout_update) websocket_api.async_register_command(hass, ws_layout_update)
websocket_api.async_register_command(hass, ws_layout_delete) websocket_api.async_register_command(hass, ws_layout_delete)
websocket_api.async_register_command(hass, ws_config_get) websocket_api.async_register_command(hass, ws_config_get)
@@ -118,7 +119,8 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
) )
return return
new_rev = current_rev + 1 new_rev = current_rev + 1
await rt.store.async_save({"layout": msg["layout"], "rev": new_rev}) await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
"layout": msg["layout"], "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev}) hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev}) connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -147,11 +149,90 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
# optimistic locking on layout/set meaningless — every drag reset the # optimistic locking on layout/set meaningless — every drag reset the
# counter to 0 (HP-1454-08) # counter to 0 (HP-1454-08)
new_rev = int(data.get("rev", 0)) + 1 new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({"layout": layout, "rev": new_rev}) await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
"layout": layout, "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev}) hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev}) connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/geometry/repair",
vol.Required("space_id"): str,
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
vol.Optional("dry_run"): bool,
vol.Optional("undo"): bool,
}
)
@websocket_api.async_response
async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Re-apply the square-canvas transform to ONE space's layout, explicitly.
For installations that hit the v1.48/v1.49 crash window: the config write
of the migration landed, the layout write did not, and the trigger fields
were already gone — markers and labels of that space are stranded in the
old coordinates with nothing able to tell (HP-1500-01). Nothing can be
detected reliably after the fact, and re-running a transform on a layout
that is already correct would corrupt it, so this NEVER runs by itself:
an administrator names the space and its old aspect, may preview with
`dry_run`, and gets a one-deep backup written in the same store write —
`undo` restores it.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may repair the layout")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
from .geometry_migration import migrate_layout
space_id = msg["space_id"]
if not valid_space_id(space_id):
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
return
async with rt.write_lock:
data = await rt.store.async_load() or {}
layout = data.get("layout") or {}
current_rev = int(data.get("rev", 0))
if msg.get("undo"):
backup = data.get("repair_backup")
if not isinstance(backup, dict) or backup.get("space") != space_id:
connection.send_error(msg["id"], "no_backup", "No repair backup stored for this space")
return
restored = dict(layout)
for key, pos in (backup.get("positions") or {}).items():
restored[key] = pos
new_rev = current_rev + 1
await rt.store.async_save({"layout": restored, "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev,
"restored": len(backup.get("positions") or {})})
return
touched = {
k: dict(v) for k, v in layout.items()
if isinstance(v, dict) and str(v.get("s")) == space_id
}
preview = {k: dict(v) for k, v in touched.items()}
migrate_layout(preview, {space_id: msg["aspect"]})
if msg.get("dry_run"):
connection.send_result(msg["id"], {
"ok": True, "dry_run": True, "moved": len(preview),
"before": touched, "after": preview,
})
return
new_layout = {**layout, **preview}
new_rev = current_rev + 1
# the backup rides the same store write: either both are durable or
# neither — the deletion-shy rules of this project apply to positions
# too
await rt.store.async_save({
"layout": new_layout, "rev": new_rev,
"repair_backup": {"space": space_id, "positions": touched},
})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "moved": len(preview)})
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "houseplan/files/migrate", vol.Required("type"): "houseplan/files/migrate",
@@ -455,7 +536,8 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
if msg["device_id"] in layout: if msg["device_id"] in layout:
del layout[msg["device_id"]] del layout[msg["device_id"]]
new_rev = int(data.get("rev", 0)) + 1 new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({"layout": layout, "rev": new_rev}) await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
"layout": layout, "rev": new_rev})
if new_rev is not None: if new_rev is not None:
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev}) hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev}) connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
+17
View File
@@ -61,4 +61,21 @@ out.devicesStretchFrame = await page.evaluate(() => {
return after[0] + after[2] > before[0] + before[2] + 20; // right edge follows the lamp return after[0] + after[2] > before[0] + before[2] + 20; // right edge follows the lamp
}); });
// -- a card BELOW other dashboard content still gets a stage (HP-1500-02) --
out.stageSurvivesContentAbove = await page.evaluate(async () => {
const spacer = document.createElement('div');
spacer.style.height = '900px';
document.body.insertBefore(spacer, document.body.firstChild);
window.dispatchEvent(new Event('resize'));
await new Promise((r) => setTimeout(r, 120));
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const h = sr.querySelector('.stage').getBoundingClientRect().height;
spacer.remove();
window.dispatchEvent(new Event('resize'));
await new Promise((r) => setTimeout(r, 120));
// the old code billed the 900px spacer as "header" and left a 0px stage
return h > 300;
});
await finish(browser, checkAll(out)); await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+18 -18
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -182,6 +182,17 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` | | `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) | | `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
**If the v1.48 migration crashed halfway** (HP-1500-01): the config write
landed, the layout write did not, and both triggers are gone — markers of that
space sit in the old coordinates and nothing in the data can prove it. The
`geom_pending` intent (v1.50.0) prevents this for any future migration, but
cannot help an install that was already stranded. There is no safe automatic
answer — re-transforming a layout that is actually correct would corrupt it —
so the fix is explicit: `houseplan/geometry/repair {space_id, aspect}`
re-applies the transform to that one space's positions. `dry_run: true`
previews, the previous positions ride the same store write as a one-deep
backup, and `undo: true` restores them. Admin-gated like every other write.
**The canvas is square, the image is not** (v1.48.0). A space used to carry an **The canvas is square, the image is not** (v1.48.0). A space used to carry an
`aspect`, and coordinates were normalised against it — x by the width, y by the `aspect`, and coordinates were normalised against it — x by the width, y by the
height. That made every geometric question depend on a per-space number for no height. That made every geometric question depend on a per-space number for no
+31
View File
@@ -1,5 +1,36 @@
# Changelog # Changelog
## v1.50.1 — 2026-07-29
**From the v1.50.0 review**
- **A card below other dashboard content gets its stage back (HP-1500-02).**
The v1.50.0 height measurement used the absolute document coordinate, so a
tall card before this one was billed as "header" and the stage collapsed to
zero. The card now measures only its own chrome plus a bounded allowance for
what the dashboard keeps above it, and re-measures on window resize; the
listener is removed on teardown.
- **The content frame can no longer be degenerate or absurd (HP-1500-03).**
A lone marker in an empty space produced a zero-area viewBox — a blank
scene; a single stored coordinate like 1e100 (any finite float passed
validation) stretched the frame until the plan was a dot, for every viewer
of the space. A near-zero axis now opens up to a floor of canvas around the
marker, points far outside the canvas envelope no longer command the frame
(they still render where they are), and the server refuses layout
coordinates outside ±4 — generous slack for an icon dragged past an edge,
not an envelope for absurdity. A real thin room keeps its tight frame, and
the gate sensor slightly past the edge still counts.
- **A repair path for installs stranded by the v1.48 migration window
(HP-1500-01).** If the old migration crashed between its two writes, the
markers of a space are left in the old coordinates with nothing in the data
able to prove it — and re-transforming a correct layout would corrupt it, so
nothing automatic is safe. `houseplan/geometry/repair {space_id, aspect}` is
the explicit answer: `dry_run` previews the exact moves, the previous
positions ride the same store write as a one-deep backup, `undo` restores
them, and routine drags no longer erase that backup. The v1.50.0
`geom_pending` protocol already protects every future migration; this covers
the installs it was too late for.
## v1.50.0 — 2026-07-28 ## v1.50.0 — 2026-07-28
**Owner's batch** **Owner's batch**
+31
View File
@@ -6,6 +6,37 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим > **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md). > изменением — как и остальная документация (см. docs/STATUS.md).
## v1.50.1 — 2026-07-29
**По ревью v1.50.0**
- **Карточка ниже другого контента дашборда снова получает сцену
(HP-1500-02).** Замер высоты в v1.50.0 брал абсолютную координату документа:
высокая карточка перед этой записывалась в «шапку», и сцена схлопывалась в
ноль. Теперь меряется только собственная обвязка плюс ограниченная поправка
на то, что дашборд держит сверху; перемер — по resize окна, слушатель
снимается при демонтаже.
- **Рамка содержимого больше не бывает вырожденной или абсурдной
(HP-1500-03).** Одинокий значок в пустом пространстве давал viewBox нулевой
площади — пустую сцену; одна сохранённая координата вида 1e100 (любое
конечное число проходило проверку) растягивала рамку так, что план
становился точкой — у всех зрителей пространства. Почти нулевая ось теперь
раскрывается до минимального кадра вокруг значка, точки далеко за холстом
рамкой не командуют (рисуются, где стоят), а сервер отклоняет координаты вне
±4 — щедрый запас для значка, утащенного за край, но не для абсурда. Узкая
комната сохраняет тесный кадр, датчик калитки чуть за краём по-прежнему
учитывается.
- **Путь восстановления для установок, застрявших в окне миграции v1.48
(HP-1500-01).** Если старая миграция упала между двумя записями, значки
пространства остаются в старых координатах, и по данным это недоказуемо — а
повторное преобразование правильных координат их испортит, поэтому ничего
автоматического тут быть не может. Явный ответ —
`houseplan/geometry/repair {space_id, aspect}`: `dry_run` показывает точные
перемещения, прежние позиции уезжают той же записью хранилища как резервная
копия глубиной один, `undo` их возвращает, и обычные перетаскивания эту
копию больше не затирают. Протокол `geom_pending` из v1.50.0 уже защищает
все будущие миграции; это — для тех, кому он опоздал.
## v1.50.0 — 2026-07-28 ## v1.50.0 — 2026-07-28
**Задачи владельца** **Задачи владельца**
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State | | Item | State |
|---|---| |---|---|
| Version | **v1.50.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance | | Version | **v1.50.1** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) | | Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) | | GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish | | CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 13 months (checked 2026-07-24) | | HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 13 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.50.0** via direct copy (HACS custom repo also installed) | | Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.50.1** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover | | Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run | | Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts | | Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
+10
View File
@@ -239,6 +239,16 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
on the plan after a reload. Same for each tap action and each fill mode on the plan after a reload. Same for each tap action and each fill mode
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and [auto: backend test_every_display_mode_the_editor_offers_is_accepted and
neighbours, test_a_marker_showing_its_value_can_be_saved] neighbours, test_a_marker_showing_its_value_can_be_saved]
- [ ] Card below other dashboard content (v1.50.1, HP-1500-02): place the card
after a tall card in a normal dashboard — the plan still gets most of the
viewport instead of a zero-height stage [auto: smoke_zoom_out]
- [ ] Frame never degenerate (v1.50.1, HP-1500-03): a space with one lone
marker opens with canvas around it, not an empty scene; an absurd stored
coordinate neither hides the plan nor is accepted by the server
[auto: unit contentBounds + backend test_layout_coordinates_are_bounded]
- [ ] Stranded migration repair (v1.50.1, HP-1500-01): geometry/repair with
dry_run previews, applies with a backup, undo restores; wrong space is
recoverable [auto: test_geometry_repair_is_explicit_previewable_and_undoable]
- [ ] Editors see the whole canvas (v1.50.0, HP-1490-03): a hand-drawn space - [ ] Editors see the whole canvas (v1.50.0, HP-1490-03): a hand-drawn space
with one small room opens content-fit in View; switching to the plan with one small room opens content-fit in View; switching to the plan
editor shows the full square with room to draw a second room far away; editor shows the full square with room to draw a second room far away;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.50.0", "version": "1.50.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.50.0", "version": "1.50.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lit": "^3.1.3", "lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.50.0", "version": "1.50.1",
"description": "Interactive house plan Lovelace card for Home Assistant", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+22 -7
View File
@@ -36,7 +36,7 @@ import { cardStyles } from './styles';
import { fitInSquare, contentBounds } from './space-geometry'; import { fitInSquare, contentBounds } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.50.0'; const CARD_VERSION = '1.50.1';
const LS_KEY = 'houseplan_card_layout_v1'; const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -246,6 +246,7 @@ class HouseplanCard extends LitElement {
private _suppressClick = false; private _suppressClick = false;
private _roViewport?: ResizeObserver; private _roViewport?: ResizeObserver;
private _roHdr?: ResizeObserver; private _roHdr?: ResizeObserver;
private _onWinResize?: () => void;
private _hdrH = 118; // measured px above the stage (see the observer in updated()) private _hdrH = 118; // measured px above the stage (see the observer in updated())
private _onboardingShown = false; // the auto space dialog is shown once per session private _onboardingShown = false; // the auto space dialog is shown once per session
@@ -436,6 +437,10 @@ class HouseplanCard extends LitElement {
this._roViewport = undefined; this._roViewport = undefined;
this._roHdr?.disconnect(); this._roHdr?.disconnect();
this._roHdr = undefined; this._roHdr = undefined;
if (this._onWinResize) {
window.removeEventListener('resize', this._onWinResize);
this._onWinResize = undefined;
}
if (this._unsubCfg) { if (this._unsubCfg) {
this._unsubCfg(); this._unsubCfg();
this._unsubCfg = null; this._unsubCfg = null;
@@ -729,15 +734,23 @@ class HouseplanCard extends LitElement {
this._roViewport = new ResizeObserver(() => this._refitView()); this._roViewport = new ResizeObserver(() => this._refitView());
this._roViewport.observe(stage); this._roViewport.observe(stage);
} }
// The stage fills the rest of the viewport. What sits above it the HA // The stage fills the rest of the viewport. What sits above it inside the
// toolbar, card margins, our own headerDEPENDS ON THE MODE: the editor // CARD depends on the mode — the editor bars used to be billed against a
// bars used to be billed against a hard-coded 118px, so entering an editor // hard-coded 118px, so entering an editor pushed the plan down by the
// pushed the plan down by the difference and cut its bottom off below the // difference. Measure our own chrome (stage top relative to the card) and
// fold. Measure where the stage actually starts instead of assuming. // allow a BOUNDED amount for what the dashboard puts above us (HA's
// toolbar). The first version used the absolute document coordinate here:
// put anything tall before the card and the "header budget" swallowed the
// whole viewport, leaving a 0px stage (HP-1500-02). Content above the card
// is the dashboard's business — it scrolls; it is not header.
const hdr = this.renderRoot.querySelector('.hdr') as HTMLElement | null; const hdr = this.renderRoot.querySelector('.hdr') as HTMLElement | null;
if (hdr && stage && !this._roHdr) { if (hdr && stage && !this._roHdr) {
const measure = () => { const measure = () => {
const t = Math.round(stage.getBoundingClientRect().top + (window.scrollY || 0)); const card = this.renderRoot.querySelector('ha-card');
if (!card) return;
const own = stage.getBoundingClientRect().top - card.getBoundingClientRect().top;
const above = Math.min(Math.max(card.getBoundingClientRect().top, 0), 120);
const t = Math.round(own + above);
if (t >= 0 && Math.abs(t - this._hdrH) > 1) this._hdrH = t; if (t >= 0 && Math.abs(t - this._hdrH) > 1) this._hdrH = t;
}; };
// a frame later: setting state straight from the observer callback makes // a frame later: setting state straight from the observer callback makes
@@ -745,6 +758,8 @@ class HouseplanCard extends LitElement {
// notifications" — the render it triggers resizes the stage again // notifications" — the render it triggers resizes the stage again
this._roHdr = new ResizeObserver(() => requestAnimationFrame(measure)); this._roHdr = new ResizeObserver(() => requestAnimationFrame(measure));
this._roHdr.observe(hdr); this._roHdr.observe(hdr);
this._onWinResize = () => requestAnimationFrame(measure);
window.addEventListener('resize', this._onWinResize);
measure(); measure();
} }
if (stage && !this._view) this._refitView(); if (stage && !this._view) this._refitView();
+21 -3
View File
@@ -86,10 +86,28 @@ export function contentBounds(
add(r.x + (r.w || 0), r.y + (r.h || 0)); add(r.x + (r.w || 0), r.y + (r.h || 0));
} }
} }
// things that live outside any room still count as content — a gate sensor // Things that live outside any room still count as content — a gate sensor
// by the fence, a camera on a pole (the card passes device positions here) // by the fence, a camera on a pole (the card passes device positions here).
for (const p of extra || []) add(p[0], p[1]); // But only within a bounded envelope around the canvas: a stored position is
// any finite number the layout schema took, and one absurd coordinate used
// to stretch the frame until the plan was a dot (HP-1500-03). A point past
// the envelope is still rendered wherever it is — it just does not command
// the opening view.
const lo = -NORM_W * 0.25, hi = NORM_W * 1.25;
for (const p of extra || []) {
if (p[0] >= lo && p[0] <= hi && p[1] >= lo && p[1] <= hi) add(p[0], p[1]);
}
if (minX > maxX || minY > maxY) return null; if (minX > maxX || minY > maxY) return null;
// A single marker (or a collinear row of them) has no area, and an SVG
// viewBox with a zero axis draws nothing at all (HP-1500-03). An axis with
// essentially no span — nothing there but icons — opens up to a floor:
// enough canvas around a lone marker to see where it stands. A REAL thin
// shape (a 100-unit corridor) keeps its tight frame; only the degenerate
// case is padded, so the threshold sits at about an icon's size.
const FLOOR = NORM_W * 0.2;
const DEGENERATE = NORM_W * 0.03;
if (maxX - minX < DEGENERATE) { const c = (minX + maxX) / 2; minX = c - FLOOR / 2; maxX = c + FLOOR / 2; }
if (maxY - minY < DEGENERATE) { const c = (minY + maxY) / 2; minY = c - FLOOR / 2; maxY = c + FLOOR / 2; }
const m = Math.max(maxX - minX, maxY - minY) * pad; const m = Math.max(maxX - minX, maxY - minY) * pad;
const x = minX - m, y = minY - m; const x = minX - m, y = minY - m;
return { x, y, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 }; return { x, y, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 };
+22
View File
@@ -123,3 +123,25 @@ test('contentBounds: devices outside every room stretch the frame', () => {
// and junk coordinates are ignored, not spread across the canvas // and junk coordinates are ignored, not spread across the canvas
assert.deepEqual(contentBounds(one, 0.05, [[NaN, 5]]), contentBounds(one)); assert.deepEqual(contentBounds(one, 0.05, [[NaN, 5]]), contentBounds(one));
}); });
test('contentBounds: never degenerate, never unbounded (HP-1500-03)', () => {
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
// a single marker has no area — the frame gets a floor instead of a 0x0 viewBox
const one = contentBounds(empty, 0.05, [[500, 500]]);
assert.ok(one.w >= 200 && one.h >= 200, 'a lone marker still frames some canvas');
assert.ok(Math.abs(one.x + one.w / 2 - 500) < 1, 'centred on the marker');
// a collinear row: the flat axis gets the floor, the long one keeps its span
const row = contentBounds(empty, 0.05, [[100, 500], [900, 500]]);
assert.ok(row.h >= 200, 'the flat axis is opened up');
assert.ok(row.w > 800, 'the long axis is untouched');
// an absurd stored coordinate does not command the frame...
const room = spaceModels({ spaces: [{
id: 's', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
}], markers: [] })[0];
assert.deepEqual(contentBounds(room, 0.05, [[1e100, 500]]), contentBounds(room));
assert.deepEqual(contentBounds(room, 0.05, [[-1e100, -1e100]]), contentBounds(room));
// ...but a device a bit past the canvas edge still counts (the gate sensor)
const near = contentBounds(room, 0.05, [[1100, 500]]);
assert.ok(near.x + near.w > 1050, 'slightly outside the canvas still stretches the frame');
});
+91
View File
@@ -1283,3 +1283,94 @@ async def test_parallel_uploads_cannot_slip_past_the_quota_together(
dir_usage, Path(hass.config.path(PLANS_DIR)) dir_usage, Path(hass.config.path(PLANS_DIR))
) )
assert after == stored + 1, "the store holds what the quota promised, not more" assert after == stored + 1, "the store holds what the quota promised, not more"
async def test_layout_coordinates_are_bounded(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1500-03: any finite float used to pass, and one stored 1e100
stretched every client's frame until the plan was invisible. Positions are
normalised to the canvas; +-4 is generous slack for an icon dragged past an
edge, not an envelope for absurdity."""
await _setup(hass)
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "houseplan/layout/update", "device_id": "d1",
"pos": {"s": "f1", "x": 1e100, "y": 0.5},
})
resp = await client.receive_json()
assert not resp["success"], "an absurd coordinate is refused at the door"
await client.send_json_auto_id({
"type": "houseplan/layout/update", "device_id": "d1",
"pos": {"s": "f1", "x": -1.2, "y": 0.5},
})
resp = await client.receive_json()
assert resp["success"], "a bit past the edge is a dragged icon, not an attack"
async def test_geometry_repair_is_explicit_previewable_and_undoable(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1500-01: an install that crashed in the v1.48 migration window has a
square config and an old-coordinates layout, and NOTHING left to tell
both triggers went with the write that succeeded. No automation can fix
that safely (a correct layout looks the same), so the repair is a person
saying "this space, this old aspect": preview first, one-deep backup with
the write, undo restores it."""
await _setup(hass)
client = await hass_ws_client(hass)
# the stranded state: nothing in the layout says it is old
await client.send_json_auto_id({
"type": "houseplan/layout/set",
"layout": {"lamp": {"s": "wide", "x": 0.2, "y": 0.1},
"other": {"s": "elsewhere", "x": 0.9, "y": 0.9}},
})
assert (await client.receive_json())["success"]
# preview does not touch the store
await client.send_json_auto_id({
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0, "dry_run": True,
})
dry = (await client.receive_json())["result"]
assert dry["moved"] == 1 and dry["after"]["lamp"]["y"] == 0.3
await client.send_json_auto_id({"type": "houseplan/layout/get"})
assert (await client.receive_json())["result"]["layout"]["lamp"]["y"] == 0.1
# the repair itself
await client.send_json_auto_id({
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0,
})
res = (await client.receive_json())["result"]
assert res["moved"] == 1
await client.send_json_auto_id({"type": "houseplan/layout/get"})
lay = (await client.receive_json())["result"]["layout"]
assert lay["lamp"] == {"s": "wide", "x": 0.2, "y": 0.3}
assert lay["other"] == {"s": "elsewhere", "x": 0.9, "y": 0.9}, "another space untouched"
# a routine drag must not eat the backup...
await client.send_json_auto_id({
"type": "houseplan/layout/update", "device_id": "other",
"pos": {"s": "elsewhere", "x": 0.5, "y": 0.5},
})
assert (await client.receive_json())["success"]
# ...because undo is the safety net for repairing the WRONG space
await client.send_json_auto_id({
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0, "undo": True,
})
res = (await client.receive_json())["result"]
assert res["restored"] == 1
await client.send_json_auto_id({"type": "houseplan/layout/get"})
lay = (await client.receive_json())["result"]["layout"]
assert lay["lamp"] == {"s": "wide", "x": 0.2, "y": 0.1}, "back to before the repair"
assert lay["other"]["x"] == 0.5, "the drag after the repair survives the undo"
# undo without a backup for that space is a clean error
await client.send_json_auto_id({
"type": "houseplan/geometry/repair", "space_id": "nosuch", "aspect": 2.0, "undo": True,
})
bad = await client.receive_json()
assert not bad["success"] and bad["error"]["code"] == "no_backup"