Compare commits

...
7 Commits
Author SHA1 Message Date
Matysh 7128ab504d Release v1.46.4
Data loss fix: collection treated a detached plan as abandoned and removed it
after an hour. Supersession stays immediate; absence is now judged per case.
2026-07-28 20:04:07 +03:00
Matysh f953a3c286 fix: the guard has to be per-case, not blanket
Protecting every file of a live space also protected the ones a commit had just
superseded, and gave rejected uploads immortality. The distinction that matters
is narrower: a space with NO plan_url has had its image detached and may want it
back; a space that has one can only be holding its own rejects. Attachments:
staging folders keep the hour, marker folders get the month.

Also: the layout event test asserted the order of separately fired bus events,
which nothing promises — it came back [2,1,3] in CI.
2026-07-28 19:59:32 +03:00
Matysh ef270d11b7 v1.46.4: detached plans were being collected as garbage — data loss
Deployed v1.46.3 to my own instance, restarted, and the startup sweep deleted
both floor plans: config/houseplan/plans/ went from f1.svg + f2.png to empty.
The backup is a SecureTar, so they are gone.

The rule was wrong, not the code. v1.46.0 introduced collection that treats
'nothing references this right now' as abandoned and gives it an hour. But
detaching a plan — switching a space to 'draw' — is a normal, reversible action,
and the editor's own comment says the file stays on disk. Those two plans had
been detached for weeks; every pass since v1.46.0 was entitled to remove them,
and the one that finally ran did.

New rule, one for every path:
  * superseded by a commit (was in the old revision, is not in the new) — goes
    immediately; that is the one thing a commit knows for certain;
  * belongs to a space or marker that still exists — never collected, at any
    age, because unreferenced is not abandoned;
  * a per-dialog staging folder (up_*) — one hour, unchanged: by construction it
    only ever holds an upload from a dialog that was never saved;
  * anything else — thirty days.

The  flag I added an hour ago is gone with it: two rules for the same
question is how this happened. Tests updated to the new grace, plus two that pin
the distinction directly.

I am sorry about the files.
2026-07-28 19:55:38 +03:00
Matysh dc24390222 Release v1.46.3
Re-check of v1.46.2: the startup sweep uses the runtime data it already has
instead of a lookup that cannot succeed during setup, and the test that was
supposed to prove it no longer passes for the wrong reason.
2026-07-28 19:45:39 +03:00
Matysh 254354bf56 test: invoke the scheduled sweep instead of faking a 24 h jump
The time-changed variant failed in CI: the timer fired but the assertion still
saw the orphan, and 'the timer fires' and 'the work happens' are different
claims anyway. HouseplanData now publishes the sweep, so the test awaits it
directly and asserts the outcome.
2026-07-28 19:35:09 +03:00
Matysh c9a60a110d chore: untrack __pycache__
.gitignore has covered it for a long time, but four .pyc files were committed
before the rule existed and kept turning up in every diff.
2026-07-28 19:31:53 +03:00
Matysh 75279308c1 v1.46.3: re-check of v1.46.2 — HP-1462-01
The startup sweep resolved its runtime data with get_data(hass), which lists
only LOADED entries — during async_setup_entry the entry is still
SETUP_IN_PROGRESS, so it always got None and degraded to removing streaming
temporaries. The real collection was then 24 hours away, and an instance that
restarts more often than that never ran it at all. It closes over the
object created a few lines above instead; the callback is unregistered with the
entry, so that matches the lifecycle.

The test that was meant to prove the previous fix passed for the wrong reason:
it seeded the strays BEFORE config/set, which collects too, so nothing was left
for the restart to find. Now seeded after the save, plus two more — one firing
the interval callback on its own, and one running a reload and a save
concurrently to assert the accepted config never references a file the sweep
removed (they share the write lock; this pins that they must).
Docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md.
2026-07-28 19:31:29 +03:00
21 changed files with 372 additions and 80 deletions
+12 -5
View File
@@ -22,7 +22,7 @@ from .const import (
)
from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files
from .store import HouseplanConfigEntry, create_data, get_data
from .store import HouseplanConfigEntry, create_data
_LOGGER = logging.getLogger(__name__)
@@ -118,16 +118,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
files_dir = Path(hass.config.path(FILES_DIR))
plans_dir = Path(hass.config.path(PLANS_DIR))
try:
data = get_data(hass)
if data is None: # entry unloaded — nothing authoritative to compare against
await hass.async_add_executor_job(sweep_upload_temps, files_dir)
return
# `data` from the closure, NOT get_data(hass): during
# async_setup_entry the entry is still SETUP_IN_PROGRESS, so
# async_loaded_entries() does not list it and the lookup returned
# None. The startup pass then silently degraded to removing
# streaming temporaries only, and the real collection waited a full
# day — restarting more often than that meant it never ran at all
# (HP-1462-01). The callback is unregistered with the entry, so
# closing over its runtime data matches the lifecycle exactly.
async with data.write_lock:
stored = await data.config_store.async_load() or {}
cfg = stored.get("config") or {}
def _collect() -> int:
n = sweep_upload_temps(files_dir)
# same config on both sides: nothing is superseded, so this
# only ever collects what the shared rules call abandoned
n += collect_attachments(files_dir, cfg, cfg)
n += collect_plans(plans_dir, cfg, cfg)
return n
@@ -138,6 +144,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
data.sweep = _sweep
await _sweep()
entry.async_on_unload(
async_track_time_interval(hass, _sweep, timedelta(hours=24))
+11 -1
View File
@@ -22,9 +22,19 @@ MAX_SIGN_PATHS = 200
# seconds ago may belong to another client's transaction that has not written
# its configuration yet (review R3-1).
PLAN_ORPHAN_TTL_S = 3600
# The scheduled sweep is a different judgement from a commit's. A commit knows
# it replaced a file; the timer only knows nobody points at one right now — and
# "nobody points at it" is a normal, reversible state. Detaching a plan (switch
# a space to "draw") leaves the image on disk on purpose, and re-attaching it
# later is a thing people do. On 2026-07-28 the hourly rule applied to that case
# and removed two plans the owner had detached weeks earlier; they were not
# recoverable. So the timer waits a month, and never touches a plan or an
# attachment that still belongs to something in the configuration.
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.46.2"
VERSION = "1.46.4"
DEFAULT_CONFIG: dict = {
"spaces": [],
@@ -2561,4 +2561,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
</button>`}
</div>
</div>
</div>`}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.2 ","background:#3ea6ff;color:#04121f;font-weight:700","");
</div>`}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.4 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.46.2"
"version": "1.46.4"
}
+44 -18
View File
@@ -14,7 +14,7 @@ import time
from pathlib import Path
from typing import Any
from .const import PLAN_ORPHAN_TTL_S
from .const import PLAN_ORPHAN_TTL_S, SCHEDULED_GRACE_S
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
_LOGGER = logging.getLogger(__name__)
@@ -122,14 +122,19 @@ def collect_attachments(
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
A file the old revision referenced and the new one does not was superseded
by this commit and goes. Anything else unreferenced is an upload that was
never saved — a cancelled dialog, a rejected write — and waits out
PLAN_ORPHAN_TTL_S first, because a fresh one may belong to a dialog the user
still has open. Never raises: it runs behind a durable write.
by this commit and goes. Otherwise a staging folder (`up_*` — only ever a
dialog that was never saved) is collected after PLAN_ORPHAN_TTL_S, and
anything else waits out SCHEDULED_GRACE_S. Never raises: it runs behind a
durable write.
"""
new_refs = attachment_refs(new_cfg)
old_refs = attachment_refs(old_cfg)
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
# Same distinction as for plans. A staging folder (`up_*`) is different: it
# only ever holds an upload from a dialog that was never saved, so the short
# rule is exactly right there even on the timer.
now_s = time.time() if now is None else now
staging_cutoff = now_s - PLAN_ORPHAN_TTL_S
cutoff = now_s - SCHEDULED_GRACE_S
removed = 0
try:
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
@@ -138,6 +143,11 @@ def collect_attachments(
return 0
removed += sweep_upload_temps(files_dir, now)
for folder in folders:
# A staging folder only ever holds an upload from a dialog that was never
# saved — unambiguous, so an hour is right. A marker's own folder is not:
# removing an attachment is deliberate, but so is re-adding one, and the
# file may have been detached rather than abandoned. Give it a month.
limit = staging_cutoff if folder.name.startswith("up_") else cutoff
try:
items = sorted(p for p in folder.iterdir() if p.is_file())
except OSError:
@@ -146,12 +156,12 @@ def collect_attachments(
rel = f"{folder.name}/{item.name}"
if rel in new_refs:
continue
try:
stale = item.stat().st_mtime < cutoff
except OSError:
stale = False
if rel not in old_refs and not stale:
continue
if rel not in old_refs: # not superseded: absence alone is weak evidence
try:
if item.stat().st_mtime >= limit:
continue
except OSError:
continue
try:
item.unlink()
removed += 1
@@ -220,6 +230,20 @@ def collect_plans(
"""
new_refs = plan_refs(new_cfg)
old_refs = plan_refs(old_cfg)
# A commit knows what it superseded. The timer only knows what nothing
# points at *right now*, and for a plan that is a reversible state: the
# editor detaches the image when a space switches to "draw" and says the
# file stays on disk. So the scheduled pass keeps anything belonging to a
# space that still exists, and waits a month for the rest.
# A space with NO plan_url has had its image detached — reversible, and the
# editor promises the file stays. A space that HAS one is different: any
# other file of its own is a superseded or rejected upload, so the short
# rule is right for those. Getting this distinction wrong (protecting
# nothing) destroyed two detached plans on 2026-07-28.
detached = {
str(sp.get("id")) for sp in (new_cfg or {}).get("spaces") or []
if not sp.get("plan_url")
}
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
removed = 0
try:
@@ -234,12 +258,14 @@ def collect_plans(
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
continue
superseded = item.name in old_refs
try:
stale = item.stat().st_mtime < cutoff
except OSError:
stale = False
if not superseded and not stale:
continue
if not superseded:
if item.name.split(".")[0] in detached:
continue # detached, not abandoned — the space is waiting for it
try:
if item.stat().st_mtime >= cutoff:
continue
except OSError:
continue
try:
item.unlink()
removed += 1
+6
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
@@ -42,6 +43,11 @@ class HouseplanData:
# One lock for every load→modify→save cycle of both stores: prevents
# lost updates from concurrent WS calls and makes the rev check atomic.
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
# Collect files nothing references any more. Set during setup, which also
# 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
# the work happens, and those are different claims.
sweep: Callable[[], Awaitable[None]] | None = None
HouseplanConfigEntry = ConfigEntry[HouseplanData]
+1 -1
View File
@@ -2561,4 +2561,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
</button>`}
</div>
</div>
</div>`}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.2 ","background:#3ea6ff;color:#04121f;font-weight:700","");
</div>`}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.4 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+1 -1
View File
@@ -2561,4 +2561,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
</button>`}
</div>
</div>
</div>`}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.2 ","background:#3ea6ff;color:#04121f;font-weight:700","");
</div>`}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.4 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+11 -2
View File
@@ -207,8 +207,17 @@ happen when somebody saves, and a file uploaded into a dialog that was then
cancelled would wait for a write that may never come. A new icon has no id yet, so its
files go to a per-dialog staging folder and move to the real id once the config
write is accepted — the same copy → save → cleanup order as a rebind.
`config/set` collects what its commit superseded, and anything unreferenced and
older than `PLAN_ORPHAN_TTL_S`.
`config/set` collects what its commit superseded — that much a commit knows for
certain. *Unreferenced* is a far weaker signal, and how weak depends on the
case. A space with `plan_url = null` had its image **detached**, which is
reversible and which the editor promises leaves the file alone: those are never
collected. A space that does have a plan can only be holding its own rejected
uploads, so `PLAN_ORPHAN_TTL_S` (1 hour) still applies to them. For attachments,
a per-dialog staging folder holds nothing but an upload from a dialog that was
never saved — one hour again — while a marker's own folder waits
`SCHEDULED_GRACE_S` (30 days). This is not theoretical caution: applying the
one-hour rule to every unreferenced file destroyed two detached plans on
2026-07-28.
**Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a
single promise: one `config/set` in flight, each carrying the revision the
+35
View File
@@ -1,5 +1,40 @@
# Changelog
## v1.46.4 — 2026-07-28 (data loss: detached plans were collected as garbage)
- **A plan you detach is no longer deleted an hour later.** Switching a space to
"draw" clears the reference and, as the editor has always said, leaves the
image on disk so you can put it back. The collection added in v1.46.0 did not
make that distinction: it treated "nothing points at this right now" as
abandoned and applied a one-hour rule. On the author's own instance the
scheduled pass then removed two floor plans that had been detached weeks
earlier, with no way to get them back. If you have detached a plan since
v1.46.0 and your instance restarted or ran for a day, check
`config/houseplan/plans/` before updating anything else — and please report it
in the Telegram chat if a file is missing.
The rule now: **a commit still removes exactly what it replaced**, because
that it knows for certain. Beyond that the question is whether "unreferenced"
means "abandoned", and the answer depends on the case. A space with no plan at
all has had one detached and may want it back — its files are never collected.
A space that does have a plan can only be holding rejected uploads of its own,
so those still go after an hour. Attachments outside a per-dialog staging
folder wait a month; a staging folder, which by construction only ever holds
an upload from a dialog that was never saved, keeps the one-hour rule.
## v1.46.3 — 2026-07-28 (re-check of v1.46.2: HP-1462-01)
- **The cleanup at startup now actually cleans up.** It looked its own runtime
data up by domain, and during startup Home Assistant does not yet consider
the integration loaded — so the lookup came back empty and the pass quietly
degraded to removing half-finished transfers, leaving the real work to a timer
24 hours away. Restart more often than that and it never ran at all. It uses
the object it was given at startup now.
- **The test that was supposed to prove this was passing for the wrong
reason.** It created the stray files *before* saving the configuration — and
saving collects too, so everything was already gone by the time the restart
happened. Rewritten to seed after the save, plus a second test that fires the
scheduled timer on its own, and a third that runs a restart and a save at the
same time and asserts the accepted configuration never points at a file the
cleanup removed.
## v1.46.2 — 2026-07-28 (re-check of v1.46.1: HP-1461-01, -02)
- **A file nobody ended up using is now cleaned up even if nothing is ever
saved again (HP-1461-01).** Collection is tied to a configuration write,
+35
View File
@@ -6,6 +6,41 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.46.4 — 2026-07-28 (потеря данных: отцеплённые планы убирались как мусор)
- **Отцеплённый план больше не удаляется через час.** Переключение пространства
в режим «нарисовать» снимает ссылку и, как редактор всегда и говорил,
оставляет картинку на диске, чтобы её можно было вернуть. Уборка, добавленная
в v1.46.0, этой разницы не делала: считала «на файл сейчас никто не
ссылается» синонимом «файл брошен» и применяла часовое правило. На установке
автора плановый проход в итоге удалил два плана этажей, отцеплённых
несколькими неделями раньше, — восстановить их было нечем. Если вы отцепляли
план после v1.46.0 и инстанс перезапускался или проработал сутки — загляните в
`config/houseplan/plans/` и напишите в Telegram-чат, если файла нет.
Правило теперь такое: **коммит по-прежнему удаляет ровно то, что заменил**, —
это он знает наверняка. Дальше вопрос в том, означает ли «непривязан»
«брошен», и ответ зависит от случая. У пространства, у которого плана нет
вовсе, его отцепили — и, возможно, вернут: его файлы не удаляются никогда. У
пространства, у которого план есть, лишние файлы могут быть только его же
отвергнутыми загрузками — они по-прежнему уходят через час. Вложения вне
промежуточной папки диалога ждут месяц; сама промежуточная папка, где по
построению лежит только загрузка из несохранённого диалога, сохраняет часовое
правило.
## v1.46.3 — 2026-07-28 (перепроверка v1.46.2: HP-1462-01)
- **Уборка при старте действительно убирает.** Она искала свои же runtime-данные
по домену, а во время запуска Home Assistant ещё не считает интеграцию
загруженной — поэтому поиск возвращал пустоту, и проход тихо вырождался в
удаление незавершённых передач, оставляя настоящую работу таймеру через
24 часа. При перезапусках чаще, чем раз в сутки, она не выполнялась вообще.
Теперь используется объект, который у неё и так был на руках.
- **Тест, который должен был это доказать, проходил по неверной причине.** Он
создавал лишние файлы *до* сохранения конфигурации, а сохранение тоже
собирает мусор — так что к моменту перезапуска убирать было уже нечего.
Переписан: файлы создаются после сохранения; добавлен второй тест, который
дёргает плановый таймер отдельно, и третий, который запускает перезапуск и
сохранение одновременно и проверяет, что принятая конфигурация никогда не
ссылается на удалённый уборкой файл.
## v1.46.2 — 2026-07-28 (перепроверка v1.46.1: HP-1461-01, -02)
- **Файл, который в итоге никому не понадобился, убирается, даже если больше
ничего не сохраняют (HP-1461-01).** Сборка привязана к записи конфигурации —
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State |
|---|---|
| Version | **v1.46.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.46.4** 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.46.2** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.46.4** 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 |
+12 -5
View File
@@ -239,11 +239,18 @@ 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]
- [ ] Nothing accumulates on an idle instance (v1.46.2, HP-1461-01): attach a
file, cancel the dialog, and do not save anything else — an hour later (or
after a restart) the file is gone, while every file the configuration
still references is untouched
[auto: backend test_scheduled_sweep_collects_what_no_commit_will]
- [ ] Detaching a plan keeps the file (v1.46.4): switch a space to "draw",
restart, wait a day — the image is still in `config/houseplan/plans/` and
can be re-attached. Replacing a plan still removes the one it replaced,
immediately [auto: unit: test_scheduled_collection_never_takes_a_detached_plan]
- [ ] Nothing accumulates on an idle instance (v1.46.2/v1.46.3, HP-1461-01,
HP-1462-01): attach a file, cancel the dialog, and do not save anything
else — the file is gone after a restart AND after the daily pass, while
every file the configuration still references is untouched. Seed the
strays AFTER the last save, or `config/set` collects them and the check
proves nothing
[auto: backend test_startup_sweep_collects_what_no_commit_will,
test_daily_sweep_callback_collects_too, test_sweep_and_a_config_write_do_not_race]
- [ ] A drag wins over a concurrent remote move (v1.46.2, HP-1461-02): drag an
icon and, while the save is still in flight, have another window move a
different icon — your icon stays where you put it and the other one
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.46.2",
"version": "1.46.4",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+1 -1
View File
@@ -35,7 +35,7 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.46.2';
const CARD_VERSION = '1.46.4';
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';
+127 -38
View File
@@ -363,6 +363,7 @@ async def test_abandoned_uploads_are_collected_once_old(
_url, orphan = await _upload(client, "r3", b"abandoned")
old = time.time() - PLAN_ORPHAN_TTL_S - 60
os.utime(plans / orphan, (old, old))
# r3 still HAS a plan, so this really is a rejected upload — collectable
ok = await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), rev)
assert ok["success"]
@@ -609,7 +610,8 @@ async def test_layout_keeps_its_revision_and_announces_changes(
assert not bad["success"] and bad["error"]["code"] == "conflict"
await hass.async_block_till_done()
assert [e["rev"] for e in events] == [1, 2, 3]
# the bus does not promise ordering between separately fired events
assert sorted(e["rev"] for e in events) == [1, 2, 3]
async def test_uploaded_svg_is_sandboxed_and_a_pdf_is_not(
@@ -855,61 +857,148 @@ async def test_cancelling_an_upload_takes_its_temporary_with_it(
assert entry
async def test_scheduled_sweep_collects_what_no_commit_will(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1461-01: a commit collects what it superseded — but only when it runs.
Cancel a dialog after the file uploaded, or lose the connection right after,
and nothing references the file and no future write notices it. The daily
sweep used to remove only streaming temporaries, so the promise that a
cancelled attachment goes after an hour did not hold on an instance nobody
edits.
"""
async def _seed_aged(hass, names) -> None:
"""Create the given files and backdate them past the orphan TTL."""
import os
import time
from custom_components.houseplan.const import FILES_DIR, PLANS_DIR, PLAN_ORPHAN_TTL_S
from custom_components.houseplan.const import SCHEDULED_GRACE_S
await _setup(hass)
client = await hass_ws_client(hass)
files = hass.config.path(FILES_DIR)
plans = hass.config.path(PLANS_DIR)
old = time.time() - PLAN_ORPHAN_TTL_S - 60
old = time.time() - SCHEDULED_GRACE_S - 60 # past every grace
def _seed() -> None:
os.makedirs(os.path.join(files, "m5"), exist_ok=True)
os.makedirs(os.path.join(files, "up_cancelled"), exist_ok=True)
os.makedirs(plans, exist_ok=True)
for path in (
os.path.join(files, "m5", "kept.pdf"),
os.path.join(files, "up_cancelled", "manual.pdf"),
os.path.join(plans, "s5.tok.png"),
os.path.join(plans, "s5.orphan.png"),
):
def _do() -> None:
for path in names:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as fh:
fh.write(b"x")
os.utime(path, (old, old))
await hass.async_add_executor_job(_seed)
await hass.async_add_executor_job(_do)
def _paths(hass):
import os
from custom_components.houseplan.const import FILES_DIR, PLANS_DIR
files = hass.config.path(FILES_DIR)
plans = hass.config.path(PLANS_DIR)
return files, plans, {
"kept_file": os.path.join(files, "m5", "kept.pdf"),
"kept_plan": os.path.join(plans, "s5.tok.png"),
"orphan_file": os.path.join(files, "up_cancelled", "manual.pdf"),
"orphan_plan": os.path.join(plans, "deleted_space.orphan.png"),
}
async def _referenced_config() -> dict:
cfg = await _cfg([{"id": "s5", "plan_url": "/api/houseplan/content/plans/_/s5.tok.png"}])
cfg["markers"] = [
{"id": "m5", "binding": "virtual",
"pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m5/kept.pdf"}]}
]
assert (await _save(client, cfg, 0))["success"]
return cfg
async def _assert_swept(hass, p) -> None:
import os
assert await hass.async_add_executor_job(os.path.isfile, p["kept_file"]), "referenced file kept"
assert await hass.async_add_executor_job(os.path.isfile, p["kept_plan"]), "referenced plan kept"
assert not await hass.async_add_executor_job(os.path.isfile, p["orphan_file"])
assert not await hass.async_add_executor_job(os.path.isfile, p["orphan_plan"])
async def test_startup_sweep_collects_what_no_commit_will(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1461-01 / HP-1462-01: collection must not depend on a future save.
The files are seeded AFTER the configuration is stored. The previous version
of this test seeded them before, and `config/set` collects too — so it
passed without the startup pass doing anything, hiding HP-1462-01: during
setup the entry is not "loaded" yet, so looking its runtime data up by
domain returned None and the pass degraded to removing streaming
temporaries only.
"""
import os
await _setup(hass)
client = await hass_ws_client(hass)
files, _plans, p = _paths(hass)
assert (await _save(client, await _referenced_config(), 0))["success"]
await _seed_aged(hass, list(p.values()))
# reload the entry: that is what runs the sweep at startup
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
exists = lambda p: os.path.isfile(p) # noqa: E731
assert await hass.async_add_executor_job(exists, os.path.join(files, "m5", "kept.pdf"))
assert await hass.async_add_executor_job(exists, os.path.join(plans, "s5.tok.png"))
await _assert_swept(hass, p)
assert not await hass.async_add_executor_job(
exists, os.path.join(files, "up_cancelled", "manual.pdf")
), "an aged cancelled attachment is collected without any further config write"
assert not await hass.async_add_executor_job(exists, os.path.join(plans, "s5.orphan.png"))
assert not await hass.async_add_executor_job(os.path.isdir, os.path.join(files, "up_cancelled"))
os.path.isdir, os.path.join(files, "up_cancelled")
), "the emptied staging folder goes with its last file"
async def test_periodic_sweep_collects_too(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""The scheduled pass, invoked directly rather than by faking a 24 h jump.
Firing a time change proves the timer fires; awaiting the callback proves it
does the work. This asserts the second, which is the part that regressed.
"""
from custom_components.houseplan.store import get_data
await _setup(hass)
client = await hass_ws_client(hass)
_files, _plans, p = _paths(hass)
assert (await _save(client, await _referenced_config(), 0))["success"]
await _seed_aged(hass, list(p.values()))
data = get_data(hass)
assert data is not None and data.sweep is not None, "setup must publish the sweep"
await data.sweep()
await hass.async_block_till_done()
await _assert_swept(hass, p)
async def test_sweep_and_a_config_write_do_not_race(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Both take the same write lock, so an accepted config cannot lose a file.
Without it the sweep could decide a file is unreferenced, a commit could
start referencing it, and the file would go — leaving the accepted revision
pointing at nothing.
"""
import asyncio
import os
await _setup(hass)
client = await hass_ws_client(hass)
_files, plans, p = _paths(hass)
rev = (await _save(client, await _referenced_config(), 0))["result"]["rev"]
# an aged, currently unreferenced plan that the commit below adopts
newcomer = os.path.join(plans, "s5.newcomer.png")
await _seed_aged(hass, [p["kept_file"], p["kept_plan"], newcomer])
cfg2 = await _referenced_config()
cfg2["spaces"][0]["plan_url"] = "/api/houseplan/content/plans/_/s5.newcomer.png"
entry = hass.config_entries.async_entries(DOMAIN)[0]
await asyncio.gather(
hass.config_entries.async_reload(entry.entry_id), # runs the sweep
_save(client, cfg2, rev),
)
await hass.async_block_till_done()
await client.send_json_auto_id({"type": "houseplan/config/get"})
stored = (await client.receive_json())["result"]["config"]
referenced = stored["spaces"][0]["plan_url"].rsplit("/", 1)[-1]
assert await hass.async_add_executor_job(
os.path.isfile, os.path.join(plans, referenced)
), f"the accepted config points at {referenced}, which must exist"
+71 -3
View File
@@ -266,11 +266,11 @@ def test_collect_plans_keeps_a_fresh_unreferenced_upload(tmp_path):
def test_collect_plans_takes_an_aged_orphan(tmp_path):
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
collect_plans = plans.collect_plans
d = _plans(tmp_path, ["f1.keep.png"])
_plans(tmp_path, ["f1.abandoned.png"], age=PLAN_ORPHAN_TTL_S + 60)
# past the long grace, and belonging to no space in the config
_plans(tmp_path, ["f1.abandoned.png"], age=const.SCHEDULED_GRACE_S + 60)
removed = collect_plans(d, _cfg("/p/f1.keep.png"), _cfg("/p/f1.keep.png"))
assert removed == 1
assert (d / "f1.keep.png").is_file() and not (d / "f1.abandoned.png").exists()
@@ -495,7 +495,7 @@ def test_collect_attachments_supersedes_and_ages(tmp_path):
assert (files / "m1" / "new.pdf").is_file()
assert (files / "m1" / "cancelled.pdf").is_file()
old = time.time() - const.PLAN_ORPHAN_TTL_S - 60
old = time.time() - const.SCHEDULED_GRACE_S - 60
os.utime(files / "m1" / "cancelled.pdf", (old, old))
assert collect_attachments(files, _acfg("m1/new.pdf"), _acfg("m1/new.pdf")) == 1
assert not (files / "m1" / "cancelled.pdf").exists()
@@ -552,3 +552,71 @@ def test_legacy_segments_are_dropped_by_the_server():
"segments": [[1, 2, 3, 4]] * 100000,
})
assert "segments" not in out
def test_scheduled_collection_never_takes_a_detached_plan(tmp_path):
"""2026-07-28, on the author's own instance: two plans were deleted.
Detaching a plan (switching a space to "draw") is reversible and the editor
says the file stays on disk. The timer only knows "nothing points at it",
applied the one-hour orphan rule, and removed images that had been detached
weeks earlier. A commit may still collect what it superseded — it knows it
replaced something. The timer may not.
"""
import os
import time
d = tmp_path / "plans"
d.mkdir()
for n in ("f1.svg", "f2.tok.png", "gone.old.png"):
(d / n).write_bytes(b"x")
t = time.time() - const.SCHEDULED_GRACE_S - 60
os.utime(d / n, (t, t))
cfg = {"spaces": [{"id": "f1", "plan_url": None}, # detached, space alive
{"id": "f2", "plan_url": None}]} # same
assert plans.collect_plans(d, cfg, cfg) == 1
assert (d / "f1.svg").is_file(), "a detached plan of a live space is kept"
assert (d / "f2.tok.png").is_file()
assert not (d / "gone.old.png").exists(), "a plan of a space that no longer exists ages out"
# a commit still removes what it SUPERSEDED — that it knows for certain
old = {"spaces": [{"id": "f1", "plan_url": "/p/f1.svg"}, {"id": "f2", "plan_url": None}]}
new = {"spaces": [{"id": "f1", "plan_url": "/p/f1.new.png"}, {"id": "f2", "plan_url": None}]}
(d / "f1.new.png").write_bytes(b"x")
assert plans.collect_plans(d, old, new) == 1
assert not (d / "f1.svg").exists()
assert (d / "f2.tok.png").is_file(), "and still touches nothing else of a live space"
def test_attachment_grace_is_a_month_outside_a_staging_folder(tmp_path):
"""A staging folder is unambiguous; a marker folder is not.
`up_*` only ever holds an upload from a dialog that was never saved, so an
hour is right there. A marker's own folder may hold a file that is merely
unreferenced at the moment, and one hour of that turned out to be a way to
lose data (2026-07-28).
"""
import os
import time
files = tmp_path / "files"
(files / "m1").mkdir(parents=True)
(files / "up_abandoned").mkdir(parents=True)
hour_ago = time.time() - const.PLAN_ORPHAN_TTL_S - 60
month_ago = time.time() - const.SCHEDULED_GRACE_S - 60
for path, when in (
((files / "m1" / "recent.pdf"), hour_ago),
((files / "m1" / "ancient.pdf"), month_ago),
((files / "up_abandoned" / "manual.pdf"), hour_ago),
):
path.write_bytes(b"x")
os.utime(path, (when, when))
cfg = {"markers": [{"id": "m1", "pdfs": []}]}
removed = plans.collect_attachments(files, cfg, cfg)
assert (files / "m1" / "recent.pdf").is_file(), "an hour is not enough for a marker file"
assert not (files / "m1" / "ancient.pdf").exists(), "a month is"
assert not (files / "up_abandoned").exists(), "a cancelled dialog still goes after an hour"
assert removed == 2