mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
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:
@@ -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.50.0"
|
||||
VERSION = "1.50.1"
|
||||
|
||||
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.50.0"
|
||||
"version": "1.50.1"
|
||||
}
|
||||
|
||||
@@ -95,8 +95,14 @@ _TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
|
||||
_TEXT_OR_NONE = vol.Any(None, _TEXT)
|
||||
_URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
|
||||
# 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(
|
||||
{vol.Required("x"): _finite, vol.Required("y"): _finite},
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
@@ -41,6 +41,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_layout_set)
|
||||
websocket_api.async_register_command(hass, ws_geometry_repair)
|
||||
websocket_api.async_register_command(hass, ws_layout_update)
|
||||
websocket_api.async_register_command(hass, ws_layout_delete)
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
@@ -118,7 +119,8 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
return
|
||||
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})
|
||||
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
|
||||
# counter to 0 (HP-1454-08)
|
||||
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})
|
||||
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(
|
||||
{
|
||||
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:
|
||||
del layout[msg["device_id"]]
|
||||
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:
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
@@ -61,4 +61,21 @@ out.devicesStretchFrame = await page.evaluate(() => {
|
||||
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));
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+18
-18
File diff suppressed because one or more lines are too long
@@ -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/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
|
||||
`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
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# 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
|
||||
|
||||
**Owner's batch**
|
||||
|
||||
@@ -6,6 +6,37 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. 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
|
||||
|
||||
**Задачи владельца**
|
||||
|
||||
+2
-2
@@ -15,12 +15,12 @@
|
||||
|
||||
| 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) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -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
|
||||
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
|
||||
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
|
||||
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;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.50.0",
|
||||
"version": "1.50.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.50.0",
|
||||
"version": "1.50.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.50.0",
|
||||
"version": "1.50.1",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+22
-7
@@ -36,7 +36,7 @@ import { cardStyles } from './styles';
|
||||
import { fitInSquare, contentBounds } from './space-geometry';
|
||||
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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -246,6 +246,7 @@ class HouseplanCard extends LitElement {
|
||||
private _suppressClick = false;
|
||||
private _roViewport?: ResizeObserver;
|
||||
private _roHdr?: ResizeObserver;
|
||||
private _onWinResize?: () => void;
|
||||
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
|
||||
|
||||
@@ -436,6 +437,10 @@ class HouseplanCard extends LitElement {
|
||||
this._roViewport = undefined;
|
||||
this._roHdr?.disconnect();
|
||||
this._roHdr = undefined;
|
||||
if (this._onWinResize) {
|
||||
window.removeEventListener('resize', this._onWinResize);
|
||||
this._onWinResize = undefined;
|
||||
}
|
||||
if (this._unsubCfg) {
|
||||
this._unsubCfg();
|
||||
this._unsubCfg = null;
|
||||
@@ -729,15 +734,23 @@ class HouseplanCard extends LitElement {
|
||||
this._roViewport = new ResizeObserver(() => this._refitView());
|
||||
this._roViewport.observe(stage);
|
||||
}
|
||||
// The stage fills the rest of the viewport. What sits above it — the HA
|
||||
// toolbar, card margins, our own header — DEPENDS ON THE MODE: the editor
|
||||
// bars used to be billed against a hard-coded 118px, so entering an editor
|
||||
// pushed the plan down by the difference and cut its bottom off below the
|
||||
// fold. Measure where the stage actually starts instead of assuming.
|
||||
// The stage fills the rest of the viewport. What sits above it inside the
|
||||
// CARD depends on the mode — the editor bars used to be billed against a
|
||||
// hard-coded 118px, so entering an editor pushed the plan down by the
|
||||
// difference. Measure our own chrome (stage top relative to the card) and
|
||||
// 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;
|
||||
if (hdr && stage && !this._roHdr) {
|
||||
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;
|
||||
};
|
||||
// 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
|
||||
this._roHdr = new ResizeObserver(() => requestAnimationFrame(measure));
|
||||
this._roHdr.observe(hdr);
|
||||
this._onWinResize = () => requestAnimationFrame(measure);
|
||||
window.addEventListener('resize', this._onWinResize);
|
||||
measure();
|
||||
}
|
||||
if (stage && !this._view) this._refitView();
|
||||
|
||||
+21
-3
@@ -86,10 +86,28 @@ export function contentBounds(
|
||||
add(r.x + (r.w || 0), r.y + (r.h || 0));
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
for (const p of extra || []) add(p[0], p[1]);
|
||||
// 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).
|
||||
// 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;
|
||||
// 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 x = minX - m, y = minY - m;
|
||||
return { x, y, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 };
|
||||
|
||||
@@ -123,3 +123,25 @@ test('contentBounds: devices outside every room stretch the frame', () => {
|
||||
// and junk coordinates are ignored, not spread across the canvas
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -1283,3 +1283,94 @@ async def test_parallel_uploads_cannot_slip_past_the_quota_together(
|
||||
dir_usage, Path(hass.config.path(PLANS_DIR))
|
||||
)
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user