v1.50.0: the v1.49.0 review (HP-1490-01..04) and the owner's zoom batch

Owner's batch (committed to dev earlier today, released here):
- devices count as content for the default zoom;
- the editor no longer shifts the plan — the stage measures its own top
  instead of assuming 118px of header;
- zoom goes out to 0.4x, centred.

From the review:
- HP-1490-01: the square-canvas migration wrote two stores in sequence, and
  the first write deleted the aspects the second needed — a crash between
  them stranded the layout in the old coordinates with nothing able to
  finish it. The intent {space: old aspect} is durable now: saved to the
  layout store before anything moves, cleared by the same write that stores
  the migrated layout, each half idempotent behind its own trigger. The
  update event fires only after both halves are on disk. Proven at the exact
  crash boundary by a harness test that fails the layout write once.
- HP-1490-02: check_quota and the file write were two executor jobs with
  nothing between them, so N parallel uploads all measured the store before
  any of them wrote. One job under a dedicated upload_lock now — narrower
  than write_lock on purpose, a directory scan must not stall config saves.
  A failed write reserves nothing.
- HP-1490-03: the content frame fed pan, zoom, clamp AND pointer maths, so
  the editors were boxed into yesterday's drawing. Edit modes measure from
  the full square; mode switches refit rather than carry a view clamped
  against the wrong base.
- HP-1490-04: Save could outrun the proportions read and ship the previous
  file's ratio. Picking a plan clears it immediately; Save awaits the
  bounded read and stores 'unknown' over a lie.
- §5: package-lock version synced, duplicated comment removed.

New: smoke_audit_1490.mjs, migration crash-recovery pure + harness tests,
parallel-quota harness test. Inventory: 138 unit / 49 pure / 40 harness / 64
smokes.
This commit is contained in:
Matysh
2026-07-28 23:50:59 +03:00
parent 6c90e03427
commit 8c5d5ba5c5
22 changed files with 494 additions and 70 deletions
+26 -6
View File
@@ -20,7 +20,7 @@ from .const import (
PLANS_URL, PLANS_URL,
VERSION, VERSION,
) )
from .geometry_migration import migrate_config from .geometry_migration import migrate_config, migrate_layout, pending_from_config
from .plans import collect_attachments, collect_plans, sweep_upload_temps from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files from .repairs import async_check_plan_files
from .store import HouseplanConfigEntry, create_data from .store import HouseplanConfigEntry, create_data
@@ -104,20 +104,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
# normalised against a per-space aspect ratio; the canvas is now always # normalised against a per-space aspect ratio; the canvas is now always
# square and a plan is centred inside it. Nothing about the drawing changes # square and a plan is centred inside it. Nothing about the drawing changes
# — the box is padded and the numbers re-expressed against it. # — the box is padded and the numbers re-expressed against it.
# The two stores are written independently, and the lock is no transaction:
# a crash between the writes used to leave the config in square coordinates
# with the layout still in the old ones — permanently, because the config
# write had already deleted the `aspect` fields the layout half needed
# (HP-1490-01). So the intent is made durable FIRST, in the layout store,
# and each half carries its own trigger with its own write: the config half
# removes `aspect`, the layout half removes the saved intent. Whatever
# half is missing after a crash, the next start finishes exactly it.
async with data.write_lock: async with data.write_lock:
stored = await data.config_store.async_load() or {} stored = await data.config_store.async_load() or {}
cfg = stored.get("config") cfg = stored.get("config")
lay_stored = await data.store.async_load() or {} lay_stored = await data.store.async_load() or {}
layout = lay_stored.get("layout") or {} layout = lay_stored.get("layout") or {}
if cfg and migrate_config(cfg, layout): pending = {
rev = int(stored.get("rev", 0)) + 1 str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
await data.config_store.async_save({"config": cfg, "rev": rev}) }
merged = {**pending, **pending_from_config(cfg)}
if merged:
lay_rev = int(lay_stored.get("rev", 0))
if merged != pending: # 1. the durable intent, before anything moves
await data.store.async_save( await data.store.async_save(
{"layout": layout, "rev": int(lay_stored.get("rev", 0)) + 1} {"layout": layout, "rev": lay_rev, "geom_pending": merged}
) )
rev = int(stored.get("rev", 0))
if cfg and migrate_config(cfg): # 2. the config half
rev += 1
await data.config_store.async_save({"config": cfg, "rev": rev})
migrate_layout(layout, merged) # 3. the layout half + intent cleared
await data.store.async_save({"layout": layout, "rev": lay_rev + 1})
_LOGGER.info( _LOGGER.info(
"House Plan: migrated %s space(s) to the square canvas", len(cfg.get("spaces") or []) "House Plan: migrated %s space(s) to the square canvas", len(merged)
) )
# only once both halves are durable — a client refetching on this
# event must never see one migrated half and one old one
hass.bus.async_fire("houseplan_config_updated", {"rev": rev}) hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
await async_check_plan_files(hass, entry) await async_check_plan_files(hass, entry)
+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.49.0" VERSION = "1.50.0"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
@@ -106,28 +106,56 @@ def migrate_space(space: dict[str, Any]) -> bool:
return True return True
def pending_from_config(config: dict[str, Any] | None) -> dict[str, float]:
"""{space_id: old aspect} for every space still carrying one.
This is the migration INTENT. The two stores are written independently and
either write can fail, so the intent has to survive on its own: it is saved
into the layout store BEFORE anything changes (HP-1490-01), and cleared by
the same write that stores the migrated layout. A crash between the writes
leaves the intent behind, and the next start finishes the missing half —
each half is idempotent because its trigger (`aspect` in the config, the
saved intent for the layout) travels with that half's own write.
"""
out: dict[str, float] = {}
for space in (config or {}).get("spaces") or []:
if "aspect" not in space:
continue
try:
out[str(space.get("id"))] = float(space.get("aspect") or 1) or 1.0
except (TypeError, ValueError):
out[str(space.get("id"))] = 1.0
return out
def migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool: def migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
"""Migrate every space, and the marker positions that belong to them.""" """The config half: migrate every space still carrying an `aspect`.
spaces = config.get("spaces") or []
factors = {} `layout` is accepted for backward compatibility and migrated with the
for space in spaces: factors found in the config — callers that can crash between store writes
if "aspect" in space: should use `pending_from_config()` + `migrate_layout()` instead, so the
factors[str(space.get("id"))] = transform_for(space.get("aspect") or 1) layout half does not depend on state the config half just deleted.
"""
factors = pending_from_config(config)
if not factors: if not factors:
return False return False
for space in config.get("spaces") or []:
for space in spaces:
migrate_space(space) migrate_space(space)
if layout:
migrate_layout(layout, factors)
return True
def migrate_layout(layout: dict[str, Any] | None, pending: dict[str, float]) -> bool:
"""The layout half: marker and label positions of the spaces in `pending`."""
changed = False
for pos in (layout or {}).values(): for pos in (layout or {}).values():
if not isinstance(pos, dict): if not isinstance(pos, dict) or str(pos.get("s")) not in pending:
continue continue
f = factors.get(str(pos.get("s"))) dx, dy, kx, ky = transform_for(pending[str(pos.get("s"))])
if not f:
continue
dx, dy, kx, ky = f
if pos.get("x") is not None: if pos.get("x") is not None:
pos["x"] = dx + float(pos["x"]) * kx pos["x"] = dx + float(pos["x"]) * kx
if pos.get("y") is not None: if pos.get("y") is not None:
pos["y"] = dy + float(pos["y"]) * ky pos["y"] = dy + float(pos["y"]) * ky
return True changed = True
return changed
+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.49.0" "version": "1.50.0"
} }
+6
View File
@@ -43,6 +43,12 @@ class HouseplanData:
# One lock for every load→modify→save cycle of both stores: prevents # One lock for every load→modify→save cycle of both stores: prevents
# lost updates from concurrent WS calls and makes the rev check atomic. # lost updates from concurrent WS calls and makes the rev check atomic.
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock) write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
# A separate, narrower lock for the check-quota→write-file pair of an
# upload. Without it N parallel uploads all measure the store BEFORE any
# of them writes, and all pass a quota only one of them fits under
# (HP-1490-02). Separate from write_lock so a slow directory scan does not
# stall config/layout commits.
upload_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
# Collect files nothing references any more. Set during setup, which also # Collect files nothing references any more. Set during setup, which also
# runs it once and schedules it daily. Exposed so it can be invoked # runs it once and schedules it daily. Exposed so it can be invoked
# directly — a test that fakes a 24 h jump proves the timer fires, not that # directly — a test that fakes a 24 h jump proves the timer fires, not that
+15 -9
View File
@@ -653,19 +653,25 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the # (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
# files of a differently named space. # files of a differently named space.
plans_dir = Path(hass.config.path(PLANS_DIR)) plans_dir = Path(hass.config.path(PLANS_DIR))
try:
await hass.async_add_executor_job(
check_quota, plans_dir, len(raw), MAX_PLANS_BYTES, MAX_PLANS_FILES
)
except QuotaError as err:
connection.send_error(msg["id"], err.reason, err.detail)
return
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}" name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
path = plans_dir / name path = plans_dir / name
def _write() -> None: def _check_and_write() -> None:
# one executor job for the pair, under upload_lock: the measurement
# is only a bound if nothing else writes between it and our write
# (HP-1490-02). A failed write reserves nothing — the file either
# exists and is counted by the next scan, or does not and is not.
check_quota(plans_dir, len(raw), MAX_PLANS_BYTES, MAX_PLANS_FILES)
plans_dir.mkdir(parents=True, exist_ok=True) plans_dir.mkdir(parents=True, exist_ok=True)
path.write_bytes(raw) path.write_bytes(raw)
await hass.async_add_executor_job(_write) data = _runtime(hass, connection, msg["id"])
if data is None:
return
async with data.upload_lock:
try:
await hass.async_add_executor_job(_check_and_write)
except QuotaError as err:
connection.send_error(msg["id"], err.reason, err.detail)
return
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"}) connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
+75
View File
@@ -0,0 +1,75 @@
// Аудит v1.49.0: HP-1490-03 (редакторы видят весь холст) и HP-1490-04
// (Save ждёт пропорции выбранного сохранённого плана, старые не наследуются).
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 1000 }, 1);
const out = {};
// ---- HP-1490-03: content-fit только в просмотре -------------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
// рукописное пространство: одна маленькая комната в центре квадрата
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
cfg.spaces[0].rooms = [{ id: 'r1', name: 'One', area: 'living_room',
poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }];
c._serverCfg = cfg; c._model = null; c._view = null; c.requestUpdate();
await c.updateComplete;
await new Promise((r) => requestAnimationFrame(r));
const inView = c._baseVb();
o.viewIsContentFit = inView[2] < 999; // меньше холста (устройства тоже содержимое)
c._setMode('plan'); await c.updateComplete;
await new Promise((r) => requestAnimationFrame(r));
const inPlan = c._baseVb();
o.editorSeesWholeCanvas = inPlan[2] === 1000 && inPlan[3] === 1000;
const v = c._viewOr(c._baseVb());
o.editorViewCoversCanvas = v.w >= 999; // старый cropped view не пережил смену
// в редакторе можно ткнуть в дальний угол холста
o.canReachFarCorner = (() => {
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1);
return pt[0] > 900 || pt[1] > 900;
})();
c._setMode('view'); await c.updateComplete;
await new Promise((r) => requestAnimationFrame(r));
const back = c._baseVb();
o.contentFitRestored = back[2] < 999;
return o;
}));
// ---- HP-1490-04: Save ждёт aspect --------------------------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const base = c.hass.callWS;
let saved = null;
let signDelay = 500; // подпись приходит поздно
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/plans/list') return { plans: [
{ name: 'wide.svg', url: '/api/houseplan/content/plans/_/wide.svg', size: 10, modified: 1, used_by: [] },
] };
if (m.type === 'houseplan/content/sign') {
await new Promise((r) => setTimeout(r, signDelay));
const urls = {}; for (const p of m.paths) urls[p] = '/assets/wide.svg'; return { urls };
}
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
if (m.type === 'houseplan/config/get') return { config: saved || c._serverCfg, rev: c._cfgRev || 0 };
return base(m);
} };
// страница отдаёт /assets/wide.svg размером 800x200 (создан рядом)
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
await c.updateComplete;
c._useServerPlan('/api/houseplan/content/plans/_/wide.svg');
o.oldAspectCleared = c._spaceDialog.savedAspect === undefined;
// Save сразу, до прихода подписи
const p = c._saveSpaceDialog();
await p;
o.savedUrl = saved?.spaces?.[0]?.plan_url === '/api/houseplan/content/plans/_/wide.svg';
const a = saved?.spaces?.[0]?.plan_aspect;
o.savedAspectIsReal = Math.abs((a || 0) - 4) < 0.01; // 800x200
o.notTheOldAspect = a !== 1.25;
return o;
}));
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="200" viewBox="0 0 800 200"><rect width="800" height="200" fill="#eee"/></svg>

After

Width:  |  Height:  |  Size: 137 B

+7 -7
View File
File diff suppressed because one or more lines are too long
+41
View File
@@ -1,5 +1,46 @@
# Changelog # Changelog
## v1.50.0 — 2026-07-28
**Owner's batch**
- **The default zoom counts devices as content.** They are allowed to stand
outside every room — a gate sensor by the fence, a camera on a pole — and the
opening view now includes them, even on a space with no rooms at all.
- **Entering an editor no longer shifts the plan.** The stage height assumed a
fixed 118px of header, and the editor header is taller: the scene slid down
by the difference and its bottom went below the fold. The card measures where
the stage actually starts and gives it the rest of the viewport.
- **The zoom goes out as well as in.** Down to 0.4×, and zoomed out the plan
floats centred instead of being pinned to a corner.
**From the v1.49.0 review**
- **The square-canvas migration survives a crash between its two writes
(HP-1490-01).** Config and layout live in separate stores, written one after
the other, and the first write deleted the very fields the second needed — a
failure between them stranded markers in the old coordinates for good. The
migration intent is durable now, saved before anything moves and cleared by
the layout write itself; whichever half is missing after a crash, the next
start finishes exactly that half, once.
- **Parallel uploads cannot slip past the store quota together (HP-1490-02).**
N uploads all measured the store before any of them wrote, and all passed a
limit only one of them fit under. The measure-and-write pair is one atomic
step under its own lock — separate from the config lock, so a slow directory
scan does not stall saves.
- **The editors see the whole canvas again (HP-1490-03).** The content frame
also bounded pan, zoom and pointer maths, so after the first room there was
nowhere left to draw the second one. Edit modes now measure from the full
square; the view keeps its content fit, and switching modes refits instead of
carrying a view clamped against the wrong base.
- **Save waits for the proportions of a picked plan (HP-1490-04).** Saving
before the image had answered used to ship the PREVIOUS file's ratio, and the
new plan kept the old shape for good. Picking a plan clears the old ratio at
once, and Save awaits the bounded read; if it fails, "unknown" is stored —
a square fallback is honest, an inherited ratio is not.
- Release hygiene from §5: package-lock.json caught up with the package
version, and a duplicated comment in space-geometry.ts is gone.
## v1.49.0 — 2026-07-28 ## v1.49.0 — 2026-07-28
**The canvas is square** (see v1.48.0, released together with this one). **The canvas is square** (see v1.48.0, released together with this one).
+41
View File
@@ -6,6 +6,47 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим > **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md). > изменением — как и остальная документация (см. docs/STATUS.md).
## v1.50.0 — 2026-07-28
**Задачи владельца**
- **Масштаб по умолчанию считает устройства содержимым.** Им можно стоять вне
комнат — датчик калитки у забора, камера на столбе — и стартовый вид теперь
включает их, даже в пространстве совсем без комнат.
- **Вход в редактор больше не сдвигает план.** Высота сцены считалась как
«экран минус 118px шапки», а шапка редактора выше: сцена уезжала вниз на
разницу, низ пропадал за краём. Карточка измеряет, где сцена начинается на
самом деле, и отдаёт ей остаток экрана.
- **Масштаб теперь и отдаляется.** До 0.4×; в отдалении план висит по центру,
а не прилипает к углу.
**По ревью v1.49.0**
- **Миграция на квадратный холст переживает сбой между двумя записями
(HP-1490-01).** Конфиг и позиции живут в разных хранилищах, пишутся по
очереди, и первая запись удаляла именно те поля, которые нужны второй — сбой
между ними навсегда оставлял значки в старых координатах. Теперь намерение
миграции сохраняется до того, как что-либо меняется, и снимается той же
записью, что сохраняет позиции: какая половина не успела — ту следующий
запуск и доделает, ровно один раз.
- **Параллельные загрузки не проскакивают квоту вместе (HP-1490-02).** N
загрузок мерили хранилище до того, как любая из них записала файл, и все
проходили предел, под который помещалась одна. Замер и запись — один
атомарный шаг под отдельным замком; отдельным — чтобы медленный обход папки
не тормозил сохранения конфига.
- **Редакторы снова видят весь холст (HP-1490-03).** Рамка содержимого
ограничивала и панорамирование, и координаты указателя, так что после первой
комнаты рисовать вторую было негде. Режимы редактирования меряют от полного
квадрата; просмотр остаётся по содержимому, а смена режима пересчитывает
вид, вместо того чтобы тащить его прижатым не к той основе.
- **Сохранение ждёт пропорции выбранного плана (HP-1490-04).** Save до ответа
картинки записывал пропорции ПРЕДЫДУЩЕГО файла, и новый план навсегда
оставался в чужой форме. Выбор плана сразу стирает старое значение, Save
дожидается ограниченного по времени чтения; не дождался — записывается
«неизвестно»: честный квадрат лучше унаследованной формы.
- Гигиена релиза из §5: package-lock.json догнал версию пакета, задвоенный
комментарий в space-geometry.ts убран.
## v1.49.0 — 2026-07-28 ## v1.49.0 — 2026-07-28
**Холст стал квадратным** (см. v1.48.0, выпущена вместе с этой). **Холст стал квадратным** (см. v1.48.0, выпущена вместе с этой).
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State | | Item | State |
|---|---| |---|---|
| Version | **v1.49.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance | | Version | **v1.50.0** 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.49.0** via direct copy (HACS custom repo also installed) | | Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.50.0** 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 |
+17
View File
@@ -239,6 +239,23 @@ 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]
- [ ] 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;
back to View restores the content fit [auto: smoke_audit_1490]
- [ ] Save waits for a picked plan's proportions (v1.50.0, HP-1490-04): pick a
saved plan and hit Save before the thumbnail loads — the stored aspect is
the real one, never the previous file's [auto: smoke_audit_1490]
- [ ] Zoom goes below the fit (v1.50.0): minus past 100% floats the plan
centred, floor at 0.4x; entering an editor keeps the stage inside the
viewport [auto: smoke_zoom_out]
- [ ] Migration crash recovery (v1.50.0, HP-1490-01): kill HA between the two
store writes of the square migration — the next start finishes the layout
half from the saved intent
[auto: test_square_migration_finishes_after_a_crash_between_the_writes]
- [ ] Parallel upload quota (v1.50.0, HP-1490-02): two simultaneous uploads
with one slot left — exactly one succeeds
[auto: test_parallel_uploads_cannot_slip_past_the_quota_together]
- [ ] Zoom opens on the content (v1.49.0): a space with no background and one - [ ] Zoom opens on the content (v1.49.0): a space with no background and one
small room opens with that room filling the screen, with a small margin. small room opens with that room filling the screen, with a small margin.
With a background it still fits the whole image With a background it still fits the whole image
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.45.4", "version": "1.50.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.45.4", "version": "1.50.0",
"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.49.0", "version": "1.50.0",
"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",
+41 -9
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.49.0'; const CARD_VERSION = '1.50.0';
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';
@@ -1258,6 +1258,10 @@ class HouseplanCard extends LitElement {
private _baseVb(): number[] { private _baseVb(): number[] {
const m = this._spaceModel(); const m = this._spaceModel();
if (m.bg) return m.vb; if (m.bg) return m.vb;
// The EDITORS get the whole square: the content frame also bounds pan,
// zoom and pointer maths, and inside it there is nowhere to draw the next
// room or drag a marker away from the first one (HP-1490-03).
if (this._mode !== 'view') return m.vb;
// devices are content too — they may stand outside every room // devices are content too — they may stand outside every room
const pts = this._devices const pts = this._devices
.filter((d) => d.space === m.id) .filter((d) => d.space === m.id)
@@ -1676,7 +1680,15 @@ class HouseplanCard extends LitElement {
this._showToast(this._t('toast.markup_needs_server')); this._showToast(this._t('toast.markup_needs_server'));
return; return;
} }
const baseChanges = !this._spaceModel().bg && (mode === 'view') !== (this._mode === 'view');
this._mode = mode; this._mode = mode;
if (baseChanges) {
// refit against the new base: the editors measure from the full square,
// the view from the content frame — a view clamped to one is nonsense
// against the other (HP-1490-03)
this._zoom = 1;
this._view = null; // updated() refits on the next frame
}
this._path = []; this._path = [];
this._cursorPt = null; this._cursorPt = null;
this._tool = 'draw'; this._tool = 'draw';
@@ -3164,12 +3176,18 @@ class HouseplanCard extends LitElement {
} }
}; };
/** The in-flight proportions read for the last picked saved plan. Save
* awaits it rather than shipping whatever was there before (HP-1490-04). */
private _aspectJob: Promise<number> | null = null;
private _useServerPlan(url: string): void { private _useServerPlan(url: string): void {
const d = this._spaceDialog; const d = this._spaceDialog;
if (!d) return; if (!d) return;
// Attach immediately — the click should not wait for anything. // Attach immediately — the click should not wait for anything. The OLD
this._spaceDialog = { ...d, planUrl: url, planFile: null, pickSaved: false }; // file's proportions go right away: they describe the previous image, and
void this._readPlanAspect(url); // a Save racing the read must get "unknown", never "the wrong shape".
this._spaceDialog = { ...d, planUrl: url, planFile: null, pickSaved: false, savedAspect: undefined };
this._aspectJob = this._readPlanAspect(url);
} }
/** /**
@@ -3182,7 +3200,7 @@ class HouseplanCard extends LitElement {
* signature, and bind the result to THIS dialog and THIS url, or a late * signature, and bind the result to THIS dialog and THIS url, or a late
* answer would reshape whatever the user opened next. * answer would reshape whatever the user opened next.
*/ */
private async _readPlanAspect(url: string): Promise<void> { private async _readPlanAspect(url: string): Promise<number> {
for (let i = 0; i < 40; i++) { // ~6 s, then give up quietly for (let i = 0; i < 40; i++) { // ~6 s, then give up quietly
const src = this._display(url); const src = this._display(url);
if (src) { if (src) {
@@ -3196,12 +3214,14 @@ class HouseplanCard extends LitElement {
const cur = this._spaceDialog; const cur = this._spaceDialog;
if (cur && cur.planUrl === url && Number.isFinite(ratio) && ratio > 0) { if (cur && cur.planUrl === url && Number.isFinite(ratio) && ratio > 0) {
this._spaceDialog = { ...cur, savedAspect: ratio }; this._spaceDialog = { ...cur, savedAspect: ratio };
return ratio;
} }
return; return 0;
} }
await new Promise((r) => setTimeout(r, 150)); await new Promise((r) => setTimeout(r, 150));
if (this._spaceDialog?.planUrl !== url) return; // the user moved on if (this._spaceDialog?.planUrl !== url) return 0; // the user moved on
} }
return 0;
} }
private async _deleteServerPlan(name: string): Promise<void> { private async _deleteServerPlan(name: string): Promise<void> {
@@ -3269,6 +3289,17 @@ class HouseplanCard extends LitElement {
uploaded = { url: resp.url, aspect: d.planFile.aspect }; uploaded = { url: resp.url, aspect: d.planFile.aspect };
} }
// A plan picked from the server list reads its proportions from the
// image, and Save used to outrun that read: the snapshot still carried
// the PREVIOUS file's ratio and a wide plan came out at the old shape
// for good (HP-1490-04). Wait for the read — it is bounded (~6 s) and
// the dialog is already busy. Unknown stays unknown, never the old
// value: a square fallback is honest, an inherited ratio is not.
let pickedAspect: number | null = d.savedAspect || null;
if (!uploaded && d.source === 'file' && d.planUrl && !pickedAspect && this._aspectJob) {
pickedAspect = (await this._aspectJob) || null;
}
// from here on: no awaits until the save, so `sp` cannot be orphaned // from here on: no awaits until the save, so `sp` cannot be orphaned
const cfg = this._serverCfg!; const cfg = this._serverCfg!;
let sp: any; let sp: any;
@@ -3292,9 +3323,10 @@ class HouseplanCard extends LitElement {
// the image's own proportions, so it can be centred before it loads // the image's own proportions, so it can be centred before it loads
sp.plan_aspect = uploaded.aspect; sp.plan_aspect = uploaded.aspect;
} else if (d.source === 'file' && d.planUrl && d.planUrl !== sp.plan_url) { } else if (d.source === 'file' && d.planUrl && d.planUrl !== sp.plan_url) {
// picked from the server list: no upload, just a reference // picked from the server list: no upload, just a reference — and the
// previous image's proportions never survive the switch
sp.plan_url = d.planUrl; sp.plan_url = d.planUrl;
if (d.savedAspect) sp.plan_aspect = d.savedAspect; sp.plan_aspect = pickedAspect;
} }
// switching an existing space to "draw" detaches its background image // switching an existing space to "draw" detaches its background image
// (the uploaded file stays on disk; only the reference is cleared) // (the uploaded file stays on disk; only the reference is cleared)
+1 -1
View File
@@ -95,7 +95,7 @@ export function contentBounds(
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 };
} }
/** Bounding rectangle of a room (rect or polygon) in render units. *//** Bounding rectangle of a room (rect or polygon) in render units. */ /** Bounding rectangle of a room (rect or polygon) in render units. */
export function roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } { export function roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } {
if (r.poly && r.poly.length) { if (r.poly && r.poly.length) {
const xs = r.poly.map((p) => p[0]); const xs = r.poly.map((p) => p[0]);
+70
View File
@@ -34,3 +34,73 @@ async def test_unload(hass: HomeAssistant) -> None:
assert await hass.config_entries.async_unload(entry.entry_id) assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
assert entry.state.value == "not_loaded" assert entry.state.value == "not_loaded"
async def test_square_migration_finishes_after_a_crash_between_the_writes(
hass: HomeAssistant, hass_storage, monkeypatch
) -> None:
"""HP-1490-01, end to end on the real stores.
The layout write is made to fail once, AFTER the config write succeeded
the exact boundary that used to strand the layout in the old coordinates
forever, because the config write had already deleted the `aspect` fields
the layout half needed. The durable intent must finish the job on the next
setup.
"""
from custom_components.houseplan.store import HouseplanStore
hass_storage["houseplan.config"] = {
"version": 1, "data": {
"config": {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []}],
"markers": [], "settings": {}},
"rev": 3,
},
}
hass_storage["houseplan.layout"] = {
"version": 1, "data": {"layout": {"m": {"s": "f1", "x": 0.1, "y": 0.1}}, "rev": 7},
}
real_save = HouseplanStore.async_save
state = {"layout_saves": 0}
async def failing_save(self, data):
if self.key == "houseplan.layout" and "geom_pending" not in data:
state["layout_saves"] += 1
if state["layout_saves"] == 1:
raise OSError("disk full at the worst possible moment")
await real_save(self, data)
monkeypatch.setattr(HouseplanStore, "async_save", failing_save)
entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={})
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# the crash boundary: config migrated, layout not, intent saved
cfg = hass_storage["houseplan.config"]["data"]["config"]
assert "aspect" not in cfg["spaces"][0], "the config half committed"
lay = hass_storage["houseplan.layout"]["data"]
assert lay["layout"]["m"]["y"] == 0.1, "the layout half did NOT commit"
assert lay.get("geom_pending") == {"f1": 2.0}, "but the intent is durable"
# next start: the store write works again
monkeypatch.setattr(HouseplanStore, "async_save", real_save)
if entry.state.value == "loaded":
await hass.config_entries.async_unload(entry.entry_id)
await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
lay = hass_storage["houseplan.layout"]["data"]
assert lay["layout"]["m"] == {"s": "f1", "x": 0.1, "y": 0.3}, (
"the saved intent finished the layout half"
)
assert "geom_pending" not in lay, "and left with the layout write"
cfg = hass_storage["houseplan.config"]["data"]["config"]
assert cfg["spaces"][0]["view_box"] == [0.0, 0.0, 1.0, 1.0]
# a third start changes nothing — both triggers are gone
before = repr(hass_storage["houseplan.layout"]) + repr(hass_storage["houseplan.config"])
await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
assert repr(hass_storage["houseplan.layout"]) + repr(hass_storage["houseplan.config"]) == before
+43
View File
@@ -1240,3 +1240,46 @@ async def test_uploads_are_bounded_by_a_store_quota(
}) })
resp = await client.receive_json() resp = await client.receive_json()
assert not resp["success"] and resp["error"]["code"] == "too_many_files" assert not resp["success"] and resp["error"]["code"] == "too_many_files"
async def test_parallel_uploads_cannot_slip_past_the_quota_together(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
) -> None:
"""HP-1490-02: N uploads used to measure the store before any of them
wrote, so all N passed a quota only one of them fits under. The
checkwrite pair is one job under upload_lock now, so whatever the
interleaving, at most ONE of two competing uploads can take the last slot.
"""
import asyncio
import base64
from custom_components.houseplan import websocket_api as wsapi
from custom_components.houseplan.const import PLANS_DIR
from custom_components.houseplan.plans import dir_usage
from pathlib import Path
await _setup(hass)
c1 = await hass_ws_client(hass)
c2 = await hass_ws_client(hass)
_bytes, stored = await hass.async_add_executor_job(
dir_usage, Path(hass.config.path(PLANS_DIR))
)
monkeypatch.setattr(wsapi, "MAX_PLANS_FILES", stored + 1) # room for ONE
payload = base64.b64encode(b"PLAN").decode()
async def upload(client, sid):
await client.send_json_auto_id({
"type": "houseplan/plan/set", "space_id": sid, "ext": "png", "data": payload,
})
return await client.receive_json()
r1, r2 = await asyncio.gather(upload(c1, "pa"), upload(c2, "pb"))
oks = [r for r in (r1, r2) if r["success"]]
errs = [r for r in (r1, r2) if not r["success"]]
assert len(oks) == 1, "exactly one takes the last slot"
assert errs and errs[0]["error"]["code"] == "too_many_files"
_bytes2, after = await hass.async_add_executor_job(
dir_usage, Path(hass.config.path(PLANS_DIR))
)
assert after == stored + 1, "the store holds what the quota promised, not more"
+44
View File
@@ -782,6 +782,50 @@ def test_migration_runs_once_and_only_when_needed():
assert repr(cfg) == snapshot assert repr(cfg) == snapshot
def test_migration_survives_a_crash_between_the_two_store_writes():
"""HP-1490-01: the two stores are written independently and either write
can fail. The intent (space -> old aspect) is saved BEFORE anything moves
and cleared with the layout write, so whichever half is missing after a
crash, the next start finishes exactly it once.
"""
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []}]}
layout = {"m": {"s": "f1", "x": 0.1, "y": 0.1}}
# start of the migration: the intent is computed from the config
pending = gm.pending_from_config(cfg)
assert pending == {"f1": 2.0}
# the config half commits; the process dies before the layout half
assert gm.migrate_config(cfg) is True
assert gm.pending_from_config(cfg) == {}, "the trigger left with the config write"
# next start: the config offers nothing, the SAVED intent still knows
assert gm.migrate_layout(layout, pending) is True
assert layout["m"] == {"s": "f1", "x": 0.1, "y": 0.3}, "y is re-centred for a wide plan"
# and the layout half never runs twice, because the intent is cleared by
# the same write that stores the migrated layout — with no intent there is
# nothing to apply
assert gm.migrate_layout(layout, {}) is False
assert layout["m"] == {"s": "f1", "x": 0.1, "y": 0.3}
def test_migration_intent_is_the_union_of_saved_and_current():
"""A crash BEFORE the config write leaves both the intent and the aspects;
merging them must not double anything, and a space added to the config
since (there cannot be one mid-crash, but the code should not care) still
migrates."""
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []}]}
saved = {"f1": 2.0}
merged = {**saved, **gm.pending_from_config(cfg)}
assert merged == {"f1": 2.0}
layout = {"m": {"s": "f1", "x": 0.1, "y": 0.1}}
gm.migrate_config(cfg)
gm.migrate_layout(layout, merged)
assert layout["m"]["y"] == 0.3
assert cfg["spaces"][0]["view_box"] == [0.0, 0.0, 1.0, 1.0]
# ---------- store-wide limits (HP-1470-01) ---------- # ---------- store-wide limits (HP-1470-01) ----------