Release v1.46.5

Audit of every automatic deletion: a detached plan is never removed (standing
rule now in SCOPE.md), files/cleanup verifies against the stored config instead
of trusting the client, and a deleted space's plan waits thirty days.
This commit is contained in:
Matysh
2026-07-28 20:41:06 +03:00
17 changed files with 217 additions and 52 deletions
+1 -1
View File
@@ -34,7 +34,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.46.4" VERSION = "1.46.5"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
@@ -2561,4 +2561,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
</button>`} </button>`}
</div> </div>
</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.4 ","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.5 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+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.46.4" "version": "1.46.5"
} }
+18 -8
View File
@@ -240,11 +240,12 @@ def collect_plans(
# other file of its own is a superseded or rejected upload, so the short # other file of its own is a superseded or rejected upload, so the short
# rule is right for those. Getting this distinction wrong (protecting # rule is right for those. Getting this distinction wrong (protecting
# nothing) destroyed two detached plans on 2026-07-28. # nothing) destroyed two detached plans on 2026-07-28.
detached = { # The short rule fits exactly one case: a space that HAS a plan, where any
str(sp.get("id")) for sp in (new_cfg or {}).get("spaces") or [] # other file of its own can only be a superseded or rejected upload.
if not sp.get("plan_url") spaces = {str(sp.get("id")): sp.get("plan_url") for sp in (new_cfg or {}).get("spaces") or []}
} now_s = time.time() if now is None else now
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S reject_cutoff = now_s - PLAN_ORPHAN_TTL_S
cutoff = now_s - SCHEDULED_GRACE_S
removed = 0 removed = 0
try: try:
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else [] items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
@@ -259,10 +260,19 @@ def collect_plans(
continue continue
superseded = item.name in old_refs superseded = item.name in old_refs
if not superseded: if not superseded:
if item.name.split(".")[0] in detached: space = item.name.split(".")[0]
continue # detached, not abandoned — the space is waiting for it if space in spaces and not spaces[space]:
# PRODUCT RULE (owner's decision, 2026-07-28): a detached plan
# is never deleted, at any age. The space is there and currently
# has no plan — the image was detached, one click undoes that,
# and the editor says the file stays. The two errors are not
# symmetrical: a few megabytes we did not need can always be
# removed by hand, a file we should not have removed cannot be
# brought back. When in doubt, keep it.
continue
limit = reject_cutoff if space in spaces else cutoff
try: try:
if item.stat().st_mtime >= cutoff: if item.stat().st_mtime >= limit:
continue continue
except OSError: except OSError:
continue continue
+51 -17
View File
@@ -263,34 +263,68 @@ async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any])
) )
@websocket_api.async_response @websocket_api.async_response
async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Delete a marker's file folder — called only AFTER the config is committed.""" """Drop a marker folder's leftovers after its files moved elsewhere.
Called after a rebind: the files were copied to the new marker id and the
config that references them is committed, so the source folder is spent.
It used to `rmtree` the folder on the client's word alone. Two ways that
ends badly: a partial copy leaves some urls still pointing INTO this folder
(the migration deliberately does not rewrite those), and a wrong or stale
id from any client deletes a live marker's attachments outright. So the
server checks for itself — under the config lock — and removes only files
the stored configuration does not reference. Same principle as the
collector: a client may say what it no longer needs, never what may go.
"""
if not _check_write(hass, connection): if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files") connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
return return
import shutil rt = _runtime(hass, connection, msg["id"])
from pathlib import Path if rt is None:
return
from .const import FILES_DIR from .const import FILES_DIR
from .plans import attachment_refs
from .validation import sanitize_marker_id from .validation import sanitize_marker_id
mid = sanitize_marker_id(msg["marker_id"]) mid = sanitize_marker_id(msg["marker_id"])
if not mid:
connection.send_result(msg["id"], {"ok": True, "removed": False})
return
base = Path(hass.config.path(FILES_DIR)).resolve() base = Path(hass.config.path(FILES_DIR)).resolve()
target = (base / mid).resolve() target = (base / mid).resolve() if mid else base
if not str(target).startswith(str(base)) or target == base: if not mid or not str(target).startswith(str(base)) or target == base:
connection.send_result(msg["id"], {"ok": True, "removed": False}) connection.send_result(msg["id"], {"ok": True, "removed": 0, "kept": 0})
return return
def _rm() -> bool: async with rt.write_lock:
if not target.is_dir(): stored = await rt.config_store.async_load() or {}
return False refs = attachment_refs(stored.get("config") or {})
shutil.rmtree(target, ignore_errors=True)
return True
removed = await hass.async_add_executor_job(_rm) def _rm() -> tuple[int, int]:
connection.send_result(msg["id"], {"ok": True, "removed": removed}) if not target.is_dir():
return 0, 0
removed = kept = 0
for item in sorted(target.iterdir()):
if not item.is_file():
continue
if f"{mid}/{item.name}" in refs:
kept += 1
continue
try:
item.unlink()
removed += 1
except OSError as err:
_LOGGER.warning("House Plan: could not remove %s: %s", item, err)
if not kept:
try:
target.rmdir()
except OSError:
pass
return removed, kept
removed, kept = await hass.async_add_executor_job(_rm)
if kept:
_LOGGER.info(
"House Plan: kept %s file(s) in %s — the configuration still references them", kept, mid
)
connection.send_result(msg["id"], {"ok": True, "removed": removed, "kept": kept})
@websocket_api.websocket_command( @websocket_api.websocket_command(
+1 -1
View File
@@ -2561,4 +2561,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
</button>`} </button>`}
</div> </div>
</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.4 ","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.5 ","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>`} </button>`}
</div> </div>
</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.4 ","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.5 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+14 -10
View File
@@ -208,16 +208,20 @@ cancelled would wait for a write that may never come. A new icon has no id yet,
files go to a per-dialog staging folder and move to the real id once the config 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. write is accepted — the same copy → save → cleanup order as a rebind.
`config/set` collects what its commit superseded — that much a commit knows for `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 certain. *Unreferenced* is a far weaker signal, and the policy follows from one
case. A space with `plan_url = null` had its image **detached**, which is asymmetry: **a few unnecessary megabytes can always be removed by hand; a file
reversible and which the editor promises leaves the file alone: those are never we should not have removed cannot be brought back.** When the evidence is weak,
collected. A space that does have a plan can only be holding its own rejected keep the file. Owner's decision, 2026-07-28, after the one-hour rule applied to
uploads, so `PLAN_ORPHAN_TTL_S` (1 hour) still applies to them. For attachments, every unreferenced file destroyed two detached plans.
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 | Case | What it means | Rule |
`SCHEDULED_GRACE_S` (30 days). This is not theoretical caution: applying the |---|---|---|
one-hour rule to every unreferenced file destroyed two detached plans on | In the old revision, not in the new | a save replaced it | removed immediately |
2026-07-28. | Space exists, has **no** plan | detached, one click to undo | **never removed** |
| Space exists, has a plan | its own rejected upload | `PLAN_ORPHAN_TTL_S` (1 h) |
| Space no longer exists | deliberate deletion, but people misclick | `SCHEDULED_GRACE_S` (30 d) |
| Attachment in `up_*` | a dialog that was never saved | `PLAN_ORPHAN_TTL_S` (1 h) |
| Attachment in a marker folder | unreferenced, but may come back | `SCHEDULED_GRACE_S` (30 d) |
**Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a **Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a
single promise: one `config/set` in flight, each carrying the revision the single promise: one `config/set` in flight, each carrying the revision the
+20
View File
@@ -1,5 +1,25 @@
# Changelog # Changelog
## v1.46.5 — 2026-07-28 (audit of every automatic deletion)
- **A detached plan is never deleted, at any age.** v1.46.4 gave it a month;
this makes it permanent and writes the reason down where the next change will
see it. The rule, now in docs/SCOPE.md: the component may delete a file only
when a user action says so — replacing a plan, removing an attachment,
deleting a device. "Nothing points at this any more" is not such an action.
The errors are not symmetrical: wasted disk is visible, cheap and reversible;
a deleted file is none of those.
- **`houseplan/files/cleanup` no longer takes a folder on the client's word.**
After a device is rebound its files are copied to the new id and the old
folder is dropped — with `rmtree`, on whatever id the card sent. Two ways that
ends badly: a partial copy leaves some urls still pointing into that folder
(the migration deliberately does not rewrite those, so they were live links to
files being deleted), and a wrong or stale id from any client would destroy a
live device's manuals. The server now checks the stored configuration itself,
under the config lock, and removes only files nothing references.
- **A plan of a space that was deleted waits thirty days instead of an hour.**
Deleting a space is deliberate, but an hour is a short window in which to
notice it was a misclick.
## v1.46.4 — 2026-07-28 (data loss: detached plans were collected as garbage) ## 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 - **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 "draw" clears the reference and, as the editor has always said, leaves the
+20
View File
@@ -6,6 +6,26 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим > **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md). > изменением — как и остальная документация (см. docs/STATUS.md).
## v1.46.5 — 2026-07-28 (ревизия всех автоматических удалений)
- **Отцеплённый план не удаляется никогда, ни в каком возрасте.** В v1.46.4 ему
давался месяц; теперь это навсегда, и причина записана там, где её увидит
следующая правка. Правило, оно же в docs/SCOPE.md: компонент вправе удалить
файл только тогда, когда об этом говорит действие пользователя — замена
плана, удаление вложения, удаление устройства. «На это больше никто не
ссылается» таким действием не является. Ошибки несимметричны: занятое зря
место видно, стоит копейки и обратимо; удалённый файл — ничего из этого.
- **`houseplan/files/cleanup` больше не сносит папку по слову клиента.** После
перепривязки устройства файлы копируются под новый id, а старая папка
удалялась — через `rmtree`, по тому id, который прислала карточка. Два плохих
исхода: при частичном копировании часть ссылок продолжает указывать внутрь
этой папки (миграция намеренно их не переписывает — то есть это были живые
ссылки на удаляемые файлы), а неверный или устаревший id от любого клиента
уничтожил бы инструкции существующего устройства. Теперь сервер сам сверяется
с сохранённой конфигурацией, под её блокировкой, и удаляет только то, на что
никто не ссылается.
- **План удалённого пространства ждёт тридцать дней вместо часа.** Удаление
пространства осознанно, но час — короткое окно, чтобы заметить промах.
## v1.46.4 — 2026-07-28 (потеря данных: отцеплённые планы убирались как мусор) ## v1.46.4 — 2026-07-28 (потеря данных: отцеплённые планы убирались как мусор)
- **Отцеплённый план больше не удаляется через час.** Переключение пространства - **Отцеплённый план больше не удаляется через час.** Переключение пространства
в режим «нарисовать» снимает ссылку и, как редактор всегда и говорил, в режим «нарисовать» снимает ссылку и, как редактор всегда и говорил,
+12
View File
@@ -66,6 +66,18 @@ refuse locks or be added to this paragraph.
- Plan-level "security glance": one badge for "all locked / N open" (J2). - Plan-level "security glance": one badge for "all locked / N open" (J2).
- Threshold colouring for room-card metrics (J5). - Threshold colouring for room-card metrics (J5).
## Standing rule: never delete a user's file on an inference
Fixed with the owner on 2026-07-28, after automatic collection removed two
detached floor plans. The component may delete a file only when the user's
action says so — replacing a plan, removing an attachment, deleting a device.
"Nothing points at this any more" is not such an action: detaching a plan is one
click and reversible, and the editor tells the user the file stays.
The asymmetry is the whole argument. Wasted disk is visible, cheap and
reversible; a deleted file is none of those. Where the evidence is weak, keep
the file — and if a future version wants to reclaim that space, it asks.
## Out of scope — never build, point users to the right tool ## Out of scope — never build, point users to the right tool
- Automations, scenes, scripts, notifications → HA core. - Automations, scenes, scripts, notifications → HA core.
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State | | Item | State |
|---|---| |---|---|
| Version | **v1.46.4** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance | | Version | **v1.46.5** 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.46.4** via direct copy (HACS custom repo also installed) | | Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.46.5** 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 |
+9 -4
View File
@@ -239,10 +239,15 @@ 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]
- [ ] Detaching a plan keeps the file (v1.46.4): switch a space to "draw", - [ ] Detaching a plan keeps the file (v1.46.4/v1.46.5): switch a space to
restart, wait a day — the image is still in `config/houseplan/plans/` and "draw", restart, wait — the image stays in `config/houseplan/plans/`
can be re-attached. Replacing a plan still removes the one it replaced, indefinitely and can be re-attached. Replacing a plan still removes the one
immediately [auto: unit: test_scheduled_collection_never_takes_a_detached_plan] it replaced, immediately
[auto: unit: test_scheduled_collection_never_takes_a_detached_plan]
- [ ] Rebinding a device does not eat its manuals (v1.46.5): attach two files to
a device, rebind it to another HA device — both are readable afterwards.
If a copy failed, the file it failed on is still there rather than deleted
with the folder [auto: backend test_files_cleanup_keeps_referenced_files]
- [ ] Nothing accumulates on an idle instance (v1.46.2/v1.46.3, HP-1461-01, - [ ] 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 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 else — the file is gone after a restart AND after the daily pass, while
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.46.4", "version": "1.46.5",
"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",
+1 -1
View File
@@ -35,7 +35,7 @@ import './space-card';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.46.4'; const CARD_VERSION = '1.46.5';
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';
+48 -2
View File
@@ -176,10 +176,12 @@ async def test_files_migrate_copies_and_reports_mapping(
assert src_kept, "migrate must COPY, not move (review CR-2)" assert src_kept, "migrate must COPY, not move (review CR-2)"
assert other == b"OTHER" and copied == b"SOURCE" assert other == b"OTHER" and copied == b"SOURCE"
# cleanup runs only after the config is safely committed # cleanup runs only after the config is safely committed, and since v1.46.5
# reports how many files it removed rather than a bare boolean — it now also
# keeps anything the stored configuration still references
await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "old1"}) await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "old1"})
resp2 = await client.receive_json() resp2 = await client.receive_json()
assert resp2["success"] and resp2["result"]["removed"] is True assert resp2["success"] and resp2["result"]["removed"] >= 1 and resp2["result"]["kept"] == 0
assert not await hass.async_add_executor_job(lambda: os.path.isdir(src)) assert not await hass.async_add_executor_job(lambda: os.path.isdir(src))
@@ -1002,3 +1004,47 @@ async def test_sweep_and_a_config_write_do_not_race(
assert await hass.async_add_executor_job( assert await hass.async_add_executor_job(
os.path.isfile, os.path.join(plans, referenced) os.path.isfile, os.path.join(plans, referenced)
), f"the accepted config points at {referenced}, which must exist" ), f"the accepted config points at {referenced}, which must exist"
async def test_files_cleanup_keeps_referenced_files(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""v1.46.5: the client may say what it no longer needs, never what may go.
`files/cleanup` used to rmtree the folder it was handed. A partial migration
leaves some urls pointing into that folder the copy deliberately does not
rewrite the ones it could not confirm so those were live links to files
being deleted. A wrong id from any client had the same effect on a device's
manuals.
"""
import os
from custom_components.houseplan.const import FILES_DIR
await _setup(hass)
client = await hass_ws_client(hass)
folder = os.path.join(hass.config.path(FILES_DIR), "m7")
def _seed() -> None:
os.makedirs(folder, exist_ok=True)
for n in ("kept.pdf", "orphan.pdf"):
with open(os.path.join(folder, n), "wb") as fh:
fh.write(b"x")
await hass.async_add_executor_job(_seed)
cfg = await _cfg([{"id": "s7", "plan_url": None}])
cfg["markers"] = [
{"id": "other", "binding": "virtual",
"pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m7/kept.pdf"}]}
]
assert (await _save(client, cfg, 0))["success"]
await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "m7"})
resp = await client.receive_json()
assert resp["success"] and resp["result"] == {"ok": True, "removed": 1, "kept": 1}
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(folder, "kept.pdf")), (
"a file the configuration still references survives a cleanup of its folder"
)
assert not await hass.async_add_executor_job(os.path.isfile, os.path.join(folder, "orphan.pdf"))
+16 -2
View File
@@ -576,9 +576,23 @@ def test_scheduled_collection_never_takes_a_detached_plan(tmp_path):
cfg = {"spaces": [{"id": "f1", "plan_url": None}, # detached, space alive cfg = {"spaces": [{"id": "f1", "plan_url": None}, # detached, space alive
{"id": "f2", "plan_url": None}]} # same {"id": "f2", "plan_url": None}]} # same
assert plans.collect_plans(d, cfg, cfg) == 1 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 / "f1.svg").is_file(), "a detached plan of a live space is kept, at any age"
assert (d / "f2.tok.png").is_file() 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" assert not (d / "gone.old.png").exists(), "a plan of a deleted space ages out after a month"
# a space that HAS a plan: its other files can only be its own rejects
import os
import time
(d / "f9.current.png").write_bytes(b"x")
(d / "f9.reject.png").write_bytes(b"x")
hour = time.time() - const.PLAN_ORPHAN_TTL_S - 60
os.utime(d / "f9.reject.png", (hour, hour))
live = {"spaces": [{"id": "f1", "plan_url": None}, {"id": "f2", "plan_url": None},
{"id": "f9", "plan_url": "/p/f9.current.png"}]}
assert plans.collect_plans(d, live, live) == 1
assert (d / "f9.current.png").is_file()
assert not (d / "f9.reject.png").exists()
# a commit still removes what it SUPERSEDED — that it knows for certain # 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}]} old = {"spaces": [{"id": "f1", "plan_url": "/p/f1.svg"}, {"id": "f2", "plan_url": None}]}