mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d37a67c29f | ||
|
|
a66272c6f4 | ||
|
|
f4af2fe508 | ||
|
|
8e07e3c958 | ||
|
|
9868f1035f | ||
|
|
f2c9b07cc1 | ||
|
|
2c7a2f849d | ||
|
|
33e71ca96c | ||
|
|
7128ab504d | ||
|
|
f953a3c286 | ||
|
|
ef270d11b7 | ||
|
|
dc24390222 | ||
|
|
254354bf56 | ||
|
|
c9a60a110d | ||
|
|
75279308c1 |
@@ -22,7 +22,7 @@ from .const import (
|
|||||||
)
|
)
|
||||||
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, get_data
|
from .store import HouseplanConfigEntry, create_data
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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))
|
files_dir = Path(hass.config.path(FILES_DIR))
|
||||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||||
try:
|
try:
|
||||||
data = get_data(hass)
|
# `data` from the closure, NOT get_data(hass): during
|
||||||
if data is None: # entry unloaded — nothing authoritative to compare against
|
# async_setup_entry the entry is still SETUP_IN_PROGRESS, so
|
||||||
await hass.async_add_executor_job(sweep_upload_temps, files_dir)
|
# async_loaded_entries() does not list it and the lookup returned
|
||||||
return
|
# 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:
|
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") or {}
|
cfg = stored.get("config") or {}
|
||||||
|
|
||||||
def _collect() -> int:
|
def _collect() -> int:
|
||||||
n = sweep_upload_temps(files_dir)
|
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_attachments(files_dir, cfg, cfg)
|
||||||
n += collect_plans(plans_dir, cfg, cfg)
|
n += collect_plans(plans_dir, cfg, cfg)
|
||||||
return n
|
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
|
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
|
||||||
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
|
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
|
||||||
|
|
||||||
|
data.sweep = _sweep
|
||||||
await _sweep()
|
await _sweep()
|
||||||
entry.async_on_unload(
|
entry.async_on_unload(
|
||||||
async_track_time_interval(hass, _sweep, timedelta(hours=24))
|
async_track_time_interval(hass, _sweep, timedelta(hours=24))
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -22,9 +22,16 @@ MAX_SIGN_PATHS = 200
|
|||||||
# seconds ago may belong to another client's transaction that has not written
|
# seconds ago may belong to another client's transaction that has not written
|
||||||
# its configuration yet (review R3-1).
|
# its configuration yet (review R3-1).
|
||||||
PLAN_ORPHAN_TTL_S = 3600
|
PLAN_ORPHAN_TTL_S = 3600
|
||||||
|
|
||||||
|
# Kept for compatibility with anything reading it; the collectors no longer use
|
||||||
|
# a long grace at all. Every attempt to age files out ended badly — first by
|
||||||
|
# deleting detached plans, then by racing the save that was about to reference a
|
||||||
|
# retried upload. What is left is deliberately simple: files go when the user's
|
||||||
|
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
|
||||||
|
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.2"
|
VERSION = "1.46.6"
|
||||||
|
|
||||||
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.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.6 ","background:#3ea6ff;color:#04121f;font-weight:700","");
|
||||||
|
|||||||
@@ -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.2"
|
"version": "1.46.6"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,15 +121,25 @@ def collect_attachments(
|
|||||||
) -> int:
|
) -> int:
|
||||||
"""The same commit-scoped rule as `collect_plans`, for marker 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
|
A file the old revision referenced and the new one does not, whose marker
|
||||||
by this commit and goes. Anything else unreferenced is an upload that was
|
still exists, was removed on purpose — the dialog has a trash button and
|
||||||
never saved — a cancelled dialog, a rejected write — and waits out
|
promises nothing. It goes. Everything else is kept, except a staging folder
|
||||||
PLAN_ORPHAN_TTL_S first, because a fresh one may belong to a dialog the user
|
(`up_*`), which by construction only ever holds an upload from a dialog that
|
||||||
still has open. Never raises: it runs behind a durable write.
|
was never saved: those go after PLAN_ORPHAN_TTL_S. Never raises: it runs
|
||||||
|
behind a durable write.
|
||||||
"""
|
"""
|
||||||
new_refs = attachment_refs(new_cfg)
|
new_refs = attachment_refs(new_cfg)
|
||||||
old_refs = attachment_refs(old_cfg)
|
old_refs = attachment_refs(old_cfg)
|
||||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
# Removing an attachment from a device that still exists is the user saying
|
||||||
|
# "drop this one" — a trash button, no promise that anything is kept. A
|
||||||
|
# device that is GONE is a different transition, and its files follow the
|
||||||
|
# same rule as a deleted space's plan: kept.
|
||||||
|
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
|
||||||
|
# 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
|
||||||
removed = 0
|
removed = 0
|
||||||
try:
|
try:
|
||||||
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
|
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
|
||||||
@@ -138,6 +148,9 @@ def collect_attachments(
|
|||||||
return 0
|
return 0
|
||||||
removed += sweep_upload_temps(files_dir, now)
|
removed += sweep_upload_temps(files_dir, now)
|
||||||
for folder in folders:
|
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, and no device owns it.
|
||||||
|
staging = folder.name.startswith("up_")
|
||||||
try:
|
try:
|
||||||
items = sorted(p for p in folder.iterdir() if p.is_file())
|
items = sorted(p for p in folder.iterdir() if p.is_file())
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -146,12 +159,19 @@ def collect_attachments(
|
|||||||
rel = f"{folder.name}/{item.name}"
|
rel = f"{folder.name}/{item.name}"
|
||||||
if rel in new_refs:
|
if rel in new_refs:
|
||||||
continue
|
continue
|
||||||
try:
|
dropped = rel in old_refs and folder.name in live_markers
|
||||||
stale = item.stat().st_mtime < cutoff
|
if not dropped:
|
||||||
except OSError:
|
if not staging:
|
||||||
stale = False
|
# Same rule as for plans: not asked for, so kept. A file in
|
||||||
if rel not in old_refs and not stale:
|
# a device's folder that the device does not list is an
|
||||||
continue
|
# upload whose save was rejected — and ageing those out
|
||||||
|
# raced the retry that was about to reference them.
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if item.stat().st_mtime >= staging_cutoff:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
item.unlink()
|
item.unlink()
|
||||||
removed += 1
|
removed += 1
|
||||||
@@ -186,6 +206,14 @@ def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def plan_by_space(cfg: dict[str, Any] | None) -> dict[str, str]:
|
||||||
|
"""space id -> the plan file it references ('' when it has none)."""
|
||||||
|
return {
|
||||||
|
str(sp.get("id")): plan_basename(sp.get("plan_url"))
|
||||||
|
for sp in (cfg or {}).get("spaces") or []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_plan_file(name: str) -> bool:
|
def is_plan_file(name: str) -> bool:
|
||||||
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
|
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
|
||||||
parts = name.split(".")
|
parts = name.split(".")
|
||||||
@@ -220,7 +248,29 @@ def collect_plans(
|
|||||||
"""
|
"""
|
||||||
new_refs = plan_refs(new_cfg)
|
new_refs = plan_refs(new_cfg)
|
||||||
old_refs = plan_refs(old_cfg)
|
old_refs = plan_refs(old_cfg)
|
||||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
# 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.
|
||||||
|
# The short rule fits exactly one case: a space that HAS a plan, where any
|
||||||
|
# other file of its own can only be a superseded or rejected upload.
|
||||||
|
old_by_space = plan_by_space(old_cfg)
|
||||||
|
new_by_space = plan_by_space(new_cfg)
|
||||||
|
# A file that left the configuration tells us nothing on its own: replacing a
|
||||||
|
# plan, detaching one and deleting a space all look identical from
|
||||||
|
# `old_refs - new_refs`. Only the first is a deletion the user asked for
|
||||||
|
# (HP-1465-01 — the guards below were written and then never reached,
|
||||||
|
# because the code decided "superseded" before asking why).
|
||||||
|
replaced = {
|
||||||
|
name for space, name in old_by_space.items()
|
||||||
|
if new_by_space.get(space) and new_by_space[space] != name
|
||||||
|
}
|
||||||
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 []
|
||||||
@@ -233,12 +283,21 @@ def collect_plans(
|
|||||||
for item in items:
|
for item in items:
|
||||||
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
||||||
continue
|
continue
|
||||||
superseded = item.name in old_refs
|
if item.name not in replaced:
|
||||||
try:
|
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
|
||||||
stale = item.stat().st_mtime < cutoff
|
# not told to delete is kept, however long it sits there. Detaching
|
||||||
except OSError:
|
# is one click to undo and the editor says the image stays; deleting
|
||||||
stale = False
|
# a space is deliberate but the image was imported and may be
|
||||||
if not superseded and not stale:
|
# nowhere else. The errors are not symmetrical — unnecessary
|
||||||
|
# megabytes can be removed by hand, a deleted file cannot be
|
||||||
|
# brought back.
|
||||||
|
#
|
||||||
|
# There is deliberately no age rule here. An earlier version aged
|
||||||
|
# out "rejected uploads" — a file of a space that has a plan, which
|
||||||
|
# was never the plan — and that raced a save: the sweep deleted the
|
||||||
|
# upload from the failed attempt while a retry was committing a
|
||||||
|
# reference to it. A rule that can delete a file somebody is about
|
||||||
|
# to point at is not worth the disk it reclaims.
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
item.unlink()
|
item.unlink()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -42,6 +43,11 @@ 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)
|
||||||
|
# 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]
|
HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.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.6 ","background:#3ea6ff;color:#04121f;font-weight:700","");
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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.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.6 ","background:#3ea6ff;color:#04121f;font-weight:700","");
|
||||||
|
|||||||
+23
-2
@@ -207,8 +207,29 @@ 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
|
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
|
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, and anything unreferenced and
|
`config/set` collects what its commit superseded — that much a commit knows for
|
||||||
older than `PLAN_ORPHAN_TTL_S`.
|
certain. *Unreferenced* is a far weaker signal, and the policy follows from one
|
||||||
|
asymmetry: **a few unnecessary megabytes can always be removed by hand; a file
|
||||||
|
we should not have removed cannot be brought back.** When the evidence is weak,
|
||||||
|
keep the file. Owner's decision, 2026-07-28, after the one-hour rule applied to
|
||||||
|
every unreferenced file destroyed two detached plans.
|
||||||
|
|
||||||
|
The classification is by **owner**, not by "is it referenced". A file leaving
|
||||||
|
the configuration looks identical whether the plan was replaced, detached, or
|
||||||
|
its space deleted — and only the first is a deletion the user asked for. Reading
|
||||||
|
`old_refs - new_refs` and calling it "superseded" deleted a plan the moment it
|
||||||
|
was detached, under documentation promising the opposite (HP-1465-01).
|
||||||
|
|
||||||
|
| Case | What it means | Rule |
|
||||||
|
|---|---|---|
|
||||||
|
| Space in both, plan A → plan B | the user picked another image | removed immediately |
|
||||||
|
| Space in both, plan → none | detached; one click undoes it | **kept** |
|
||||||
|
| Space gone | deliberate, but the image was imported and may be nowhere else | **kept** |
|
||||||
|
| Space has a plan, plus another file of its own | an upload whose save was rejected | **kept** — ageing these out raced the retry that referenced them |
|
||||||
|
| Marker in both, attachment dropped from its list | a trash button, promising nothing | removed immediately |
|
||||||
|
| Marker gone | same call as a deleted space's plan | **kept** |
|
||||||
|
| Attachment in `up_*` | a dialog that was never saved; no device owns it | `PLAN_ORPHAN_TTL_S` (1 h) |
|
||||||
|
| Marker there, file it never listed | a rejected upload | **kept**, same reason |
|
||||||
|
|
||||||
**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
|
||||||
|
|||||||
@@ -1,5 +1,81 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.46.6 — 2026-07-28 (the detach promise, actually kept this time)
|
||||||
|
- **Switching a space to "draw" no longer deletes its image.** v1.46.4 and
|
||||||
|
v1.46.5 said it did not, and the scheduled cleanup indeed left detached plans
|
||||||
|
alone — but the save itself deleted the file the moment the reference was
|
||||||
|
cleared, before any of those guards were reached. The cause: a file that left
|
||||||
|
the configuration was called "superseded", and from that difference alone
|
||||||
|
replacing a plan, detaching one and deleting its space are indistinguishable.
|
||||||
|
Only the first is a deletion anybody asked for. The transition is now
|
||||||
|
classified by the space that owned the file, and the same distinction applies
|
||||||
|
to attachments: dropping one from a device that still exists removes it,
|
||||||
|
deleting the device keeps its manuals.
|
||||||
|
- **A plan whose space was deleted is kept**, rather than the thirty days
|
||||||
|
v1.46.5 promised — thirty days measured from the file's age is meaningless
|
||||||
|
anyway, since it was usually uploaded months earlier.
|
||||||
|
- **Nothing is deleted for being old any more**, except a per-dialog staging
|
||||||
|
folder. The rule that aged out "rejected uploads" turned out to race a retry:
|
||||||
|
the cleanup removed the file from a failed save while the next attempt was
|
||||||
|
committing a reference to it. A rule that can delete a file somebody is about
|
||||||
|
to point at is not worth the disk it reclaims. Files therefore go when an
|
||||||
|
action says so, and otherwise stay.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- **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)
|
## 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
|
- **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,
|
saved again (HP-1461-01).** Collection is tied to a configuration write,
|
||||||
|
|||||||
@@ -6,6 +6,82 @@
|
|||||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||||
|
|
||||||
|
## v1.46.6 — 2026-07-28 (обещание про отцепление, теперь выполненное)
|
||||||
|
- **Переключение пространства в «нарисовать» больше не удаляет его картинку.**
|
||||||
|
v1.46.4 и v1.46.5 утверждали, что не удаляет, и плановая уборка действительно
|
||||||
|
отцеплённые планы не трогала — но само сохранение удаляло файл в тот момент,
|
||||||
|
когда снималась ссылка, ещё до всех этих проверок. Причина: файл, покинувший
|
||||||
|
конфигурацию, считался «заменённым», а по одной этой разнице замену плана,
|
||||||
|
отцепление и удаление пространства различить невозможно. Удалением, о котором
|
||||||
|
просили, является только первое. Теперь переход классифицируется по
|
||||||
|
пространству-владельцу, и та же разница применяется к вложениям: убрали файл у
|
||||||
|
существующего устройства — он удаляется, удалили устройство — его инструкции
|
||||||
|
остаются.
|
||||||
|
- **План удалённого пространства сохраняется**, а не тридцать дней, как обещала
|
||||||
|
v1.46.5: тридцать дней по возрасту файла всё равно бессмысленны — обычно он
|
||||||
|
загружен месяцы назад.
|
||||||
|
- **По возрасту больше не удаляется ничего**, кроме промежуточной папки диалога.
|
||||||
|
Правило, которое вычищало «отвергнутые загрузки», оказалось в гонке с
|
||||||
|
повторной попыткой: уборка удаляла файл неудавшегося сохранения ровно тогда,
|
||||||
|
когда следующая попытка коммитила ссылку на него. Правило, способное удалить
|
||||||
|
файл, на который кто-то вот-вот сошлётся, не стоит освобождаемого места.
|
||||||
|
Файлы уходят по действию, в остальных случаях остаются.
|
||||||
|
|
||||||
|
## 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.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)
|
## v1.46.2 — 2026-07-28 (перепроверка v1.46.1: HP-1461-01, -02)
|
||||||
- **Файл, который в итоге никому не понадобился, убирается, даже если больше
|
- **Файл, который в итоге никому не понадобился, убирается, даже если больше
|
||||||
ничего не сохраняют (HP-1461-01).** Сборка привязана к записи конфигурации —
|
ничего не сохраняют (HP-1461-01).** Сборка привязана к записи конфигурации —
|
||||||
|
|||||||
@@ -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
@@ -15,12 +15,12 @@
|
|||||||
|
|
||||||
| Item | State |
|
| Item | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Version | **v1.46.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
| Version | **v1.46.6** 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 1–3 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 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.6** 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 |
|
||||||
|
|||||||
+20
-5
@@ -239,11 +239,26 @@ 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]
|
||||||
- [ ] Nothing accumulates on an idle instance (v1.46.2, HP-1461-01): attach a
|
- [ ] Detaching a plan keeps the file (v1.46.6): switch a space to "draw" and
|
||||||
file, cancel the dialog, and do not save anything else — an hour later (or
|
SAVE — the image is still in `config/houseplan/plans/` right afterwards,
|
||||||
after a restart) the file is gone, while every file the configuration
|
and after a restart, and can be re-attached. Deleting the space keeps it
|
||||||
still references is untouched
|
too. Replacing a plan still removes the one it replaced, immediately.
|
||||||
[auto: backend test_scheduled_sweep_collects_what_no_commit_will]
|
Check straight after the save: the earlier bug deleted the file at that
|
||||||
|
moment, while every scheduled-pass test passed
|
||||||
|
[auto: unit: test_plan_collection_matrix, test_attachment_collection_matrix,
|
||||||
|
backend test_detaching_a_plan_keeps_the_file]
|
||||||
|
- [ ] 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,
|
||||||
|
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
|
- [ ] 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
|
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
|
different icon — your icon stays where you put it and the other one
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.46.2",
|
"version": "1.46.6",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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.2';
|
const CARD_VERSION = '1.46.6';
|
||||||
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';
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|
||||||
@@ -340,34 +342,41 @@ async def test_commit_does_not_collect_another_client_s_uncommitted_upload(
|
|||||||
assert not (plans / pa).exists()
|
assert not (plans / pa).exists()
|
||||||
|
|
||||||
|
|
||||||
async def test_abandoned_uploads_are_collected_once_old(
|
async def test_a_rejected_upload_is_kept_not_aged_out(
|
||||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A rejected upload must not accumulate forever — but only age may free it."""
|
"""v1.46.6: age is never a reason to delete a plan file.
|
||||||
|
|
||||||
|
It used to be, for "a file of a space that has a plan and never was one" —
|
||||||
|
an upload whose save had failed. That raced the retry: the sweep removed the
|
||||||
|
file while the next save was committing a reference to it. Keeping it costs
|
||||||
|
a few megabytes nobody can lose.
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from custom_components.houseplan.const import PLANS_DIR, PLAN_ORPHAN_TTL_S
|
from custom_components.houseplan.const import PLANS_DIR, SCHEDULED_GRACE_S
|
||||||
|
from custom_components.houseplan.store import get_data
|
||||||
|
|
||||||
await _setup(hass)
|
await _setup(hass)
|
||||||
client = await hass_ws_client(hass)
|
client = await hass_ws_client(hass)
|
||||||
plans = Path(hass.config.path(PLANS_DIR))
|
plans = hass.config.path(PLANS_DIR)
|
||||||
plans.mkdir(parents=True, exist_ok=True)
|
|
||||||
for stale in plans.glob("r3.*"):
|
|
||||||
stale.unlink()
|
|
||||||
|
|
||||||
url0, p0 = await _upload(client, "r3", b"zero")
|
url0, p0 = await _upload(client, "r3", b"zero")
|
||||||
rev = (await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), 0))["result"]["rev"]
|
rev = (await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), 0))["result"]["rev"]
|
||||||
|
|
||||||
_url, orphan = await _upload(client, "r3", b"abandoned")
|
_url, orphan = await _upload(client, "r3", b"never saved")
|
||||||
old = time.time() - PLAN_ORPHAN_TTL_S - 60
|
old = time.time() - SCHEDULED_GRACE_S * 12
|
||||||
os.utime(plans / orphan, (old, old))
|
os.utime(os.path.join(plans, orphan), (old, old))
|
||||||
|
|
||||||
ok = await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), rev)
|
# a commit, and the scheduled pass, and any amount of age: it stays
|
||||||
assert ok["success"]
|
assert (await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), rev))["success"]
|
||||||
assert not (plans / orphan).exists(), "an aged, unreferenced upload is collected"
|
data = get_data(hass)
|
||||||
assert (plans / p0).is_file(), "the referenced plan is never touched"
|
await data.sweep()
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, orphan))
|
||||||
|
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, p0))
|
||||||
|
|
||||||
|
|
||||||
async def test_collection_ignores_files_that_are_not_plans(
|
async def test_collection_ignores_files_that_are_not_plans(
|
||||||
@@ -609,7 +618,8 @@ async def test_layout_keeps_its_revision_and_announces_changes(
|
|||||||
assert not bad["success"] and bad["error"]["code"] == "conflict"
|
assert not bad["success"] and bad["error"]["code"] == "conflict"
|
||||||
|
|
||||||
await hass.async_block_till_done()
|
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(
|
async def test_uploaded_svg_is_sandboxed_and_a_pdf_is_not(
|
||||||
@@ -855,61 +865,250 @@ async def test_cancelling_an_upload_takes_its_temporary_with_it(
|
|||||||
assert entry
|
assert entry
|
||||||
|
|
||||||
|
|
||||||
async def test_scheduled_sweep_collects_what_no_commit_will(
|
async def _seed_aged(hass, names) -> None:
|
||||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
"""Create the given files and backdate them past the orphan TTL."""
|
||||||
) -> 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.
|
|
||||||
"""
|
|
||||||
import os
|
import os
|
||||||
import time
|
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)
|
old = time.time() - SCHEDULED_GRACE_S - 60 # past every grace
|
||||||
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
|
|
||||||
|
|
||||||
def _seed() -> None:
|
def _do() -> None:
|
||||||
os.makedirs(os.path.join(files, "m5"), exist_ok=True)
|
for path in names:
|
||||||
os.makedirs(os.path.join(files, "up_cancelled"), exist_ok=True)
|
os.makedirs(os.path.dirname(path), 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"),
|
|
||||||
):
|
|
||||||
with open(path, "wb") as fh:
|
with open(path, "wb") as fh:
|
||||||
fh.write(b"x")
|
fh.write(b"x")
|
||||||
os.utime(path, (old, old))
|
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"),
|
||||||
|
# a plan file is never collected by age any more; keep one around and
|
||||||
|
# assert exactly that
|
||||||
|
"kept_reject": os.path.join(plans, "s5.reject.png"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _referenced_config() -> dict:
|
||||||
cfg = await _cfg([{"id": "s5", "plan_url": "/api/houseplan/content/plans/_/s5.tok.png"}])
|
cfg = await _cfg([{"id": "s5", "plan_url": "/api/houseplan/content/plans/_/s5.tok.png"}])
|
||||||
cfg["markers"] = [
|
cfg["markers"] = [
|
||||||
{"id": "m5", "binding": "virtual",
|
{"id": "m5", "binding": "virtual",
|
||||||
"pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m5/kept.pdf"}]}
|
"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"]), (
|
||||||
|
"a staging folder from a dialog nobody saved is the one thing age collects"
|
||||||
|
)
|
||||||
|
assert await hass.async_add_executor_job(os.path.isfile, p["kept_reject"]), (
|
||||||
|
"a plan file is never removed for being old"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||||
assert await hass.config_entries.async_reload(entry.entry_id)
|
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
exists = lambda p: os.path.isfile(p) # noqa: E731
|
await _assert_swept(hass, p)
|
||||||
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"))
|
|
||||||
assert not await hass.async_add_executor_job(
|
assert not await hass.async_add_executor_job(
|
||||||
exists, os.path.join(files, "up_cancelled", "manual.pdf")
|
os.path.isdir, os.path.join(files, "up_cancelled")
|
||||||
), "an aged cancelled attachment is collected without any further config write"
|
), "the emptied staging folder goes with its last file"
|
||||||
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"))
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
# Drive the sweep directly rather than through a reload: an entry reload has
|
||||||
|
# an unload window in which any WS call legitimately answers `not_ready`, so
|
||||||
|
# a save racing THAT proves nothing about the lock and fails at random.
|
||||||
|
from custom_components.houseplan.store import get_data
|
||||||
|
|
||||||
|
data = get_data(hass)
|
||||||
|
assert data is not None and data.sweep is not None
|
||||||
|
_swept, saved = await asyncio.gather(data.sweep(), _save(client, cfg2, rev))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# assert the CONCRETE outcome: a save that came back `not_ready` would leave
|
||||||
|
# the old config pointing at the old file and satisfy a vaguer check
|
||||||
|
assert saved["success"], saved.get("error")
|
||||||
|
await client.send_json_auto_id({"type": "houseplan/config/get"})
|
||||||
|
stored = (await client.receive_json())["result"]["config"]
|
||||||
|
assert stored["spaces"][0]["plan_url"].endswith("s5.newcomer.png")
|
||||||
|
assert await hass.async_add_executor_job(
|
||||||
|
os.path.isfile, os.path.join(plans, "s5.newcomer.png")
|
||||||
|
), "the file the accepted config points at 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"))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_detaching_a_plan_keeps_the_file(
|
||||||
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||||
|
) -> None:
|
||||||
|
"""HP-1465-01, through the real save — where the earlier tests never looked.
|
||||||
|
|
||||||
|
Every check for this lived in the pure collector with old and new config
|
||||||
|
equal, i.e. the scheduled pass. The transition that matters is a save, and
|
||||||
|
there the file was deleted the moment the reference was cleared.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from custom_components.houseplan.const import PLANS_DIR
|
||||||
|
|
||||||
|
await _setup(hass)
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
plans = hass.config.path(PLANS_DIR)
|
||||||
|
|
||||||
|
url, name = await _upload(client, "d1", b"PLAN", ext="png")
|
||||||
|
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url}]), 0))["result"]["rev"]
|
||||||
|
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
|
||||||
|
|
||||||
|
# detach: the space stays, its plan does not
|
||||||
|
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": None}]), rev))["result"]["rev"]
|
||||||
|
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name)), (
|
||||||
|
"the editor says the image stays on disk — it has to actually stay"
|
||||||
|
)
|
||||||
|
|
||||||
|
# a restart does not change its mind either
|
||||||
|
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||||
|
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
|
||||||
|
|
||||||
|
# re-attach, then replace: THAT removes the one it replaced
|
||||||
|
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url}]), rev))["result"]["rev"]
|
||||||
|
url2, name2 = await _upload(client, "d1", b"NEWPLAN", ext="png")
|
||||||
|
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url2}]), rev))["result"]["rev"]
|
||||||
|
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name2))
|
||||||
|
assert not await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
|
||||||
|
|
||||||
|
# and deleting the space keeps its plan
|
||||||
|
await _save(client, await _cfg([]), rev)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name2))
|
||||||
|
|||||||
@@ -265,17 +265,6 @@ def test_collect_plans_keeps_a_fresh_unreferenced_upload(tmp_path):
|
|||||||
assert (d / "f1.inflight.png").is_file()
|
assert (d / "f1.inflight.png").is_file()
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
def test_collect_plans_never_touches_a_referenced_or_foreign_file(tmp_path):
|
def test_collect_plans_never_touches_a_referenced_or_foreign_file(tmp_path):
|
||||||
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
|
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
|
||||||
collect_plans = plans.collect_plans
|
collect_plans = plans.collect_plans
|
||||||
@@ -477,31 +466,6 @@ def test_attachment_refs_reads_marker_urls():
|
|||||||
assert plans.attachment_refs(cfg) == set()
|
assert plans.attachment_refs(cfg) == set()
|
||||||
|
|
||||||
|
|
||||||
def test_collect_attachments_supersedes_and_ages(tmp_path):
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
collect_attachments = plans.collect_attachments
|
|
||||||
files = tmp_path / "files"
|
|
||||||
(files / "m1").mkdir(parents=True)
|
|
||||||
for n in ("old.pdf", "new.pdf", "cancelled.pdf"):
|
|
||||||
(files / "m1" / n).write_bytes(b"x")
|
|
||||||
|
|
||||||
# the commit swapped old.pdf for new.pdf; cancelled.pdf is a fresh upload
|
|
||||||
# nobody saved — it may belong to a dialog that is still open
|
|
||||||
removed = collect_attachments(files, _acfg("m1/old.pdf"), _acfg("m1/new.pdf"))
|
|
||||||
assert removed == 1
|
|
||||||
assert not (files / "m1" / "old.pdf").exists()
|
|
||||||
assert (files / "m1" / "new.pdf").is_file()
|
|
||||||
assert (files / "m1" / "cancelled.pdf").is_file()
|
|
||||||
|
|
||||||
old = time.time() - const.PLAN_ORPHAN_TTL_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()
|
|
||||||
assert (files / "m1" / "new.pdf").is_file()
|
|
||||||
|
|
||||||
|
|
||||||
def test_collect_attachments_removes_the_empty_folder_and_never_raises(tmp_path):
|
def test_collect_attachments_removes_the_empty_folder_and_never_raises(tmp_path):
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -552,3 +516,153 @@ def test_legacy_segments_are_dropped_by_the_server():
|
|||||||
"segments": [[1, 2, 3, 4]] * 100000,
|
"segments": [[1, 2, 3, 4]] * 100000,
|
||||||
})
|
})
|
||||||
assert "segments" not in out
|
assert "segments" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def _aged(path, seconds):
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
t = time.time() - seconds
|
||||||
|
os.utime(path, (t, t))
|
||||||
|
|
||||||
|
|
||||||
|
def _sp(sid, url):
|
||||||
|
return {"id": sid, "plan_url": f"/api/houseplan/content/plans/_/{url}" if url else None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_collection_matrix(tmp_path):
|
||||||
|
"""Which config transition means "the user asked for this file to go"?
|
||||||
|
|
||||||
|
`old_refs - new_refs` cannot tell replace, detach and delete-space apart —
|
||||||
|
they look identical. v1.46.4/v1.46.5 added guards for detach and only ever
|
||||||
|
reached them on the scheduled pass, so the commit itself still deleted a
|
||||||
|
detached plan the moment it was detached (HP-1465-01). One case is a
|
||||||
|
deletion the user asked for; the rest are kept.
|
||||||
|
"""
|
||||||
|
collect = plans.collect_plans
|
||||||
|
d = tmp_path / "plans"
|
||||||
|
d.mkdir()
|
||||||
|
|
||||||
|
def seed(*names):
|
||||||
|
for n in names:
|
||||||
|
(d / n).write_bytes(b"x")
|
||||||
|
_aged(d / n, const.SCHEDULED_GRACE_S * 2) # old enough for any rule
|
||||||
|
|
||||||
|
# 1. replace: the user picked a different image for the same space
|
||||||
|
seed("f1.old.png", "f1.new.png")
|
||||||
|
assert collect(d, {"spaces": [_sp("f1", "f1.old.png")]},
|
||||||
|
{"spaces": [_sp("f1", "f1.new.png")]}) == 1
|
||||||
|
assert not (d / "f1.old.png").exists() and (d / "f1.new.png").is_file()
|
||||||
|
|
||||||
|
# 2. detach: same space, switched to "draw"
|
||||||
|
seed("f2.png")
|
||||||
|
assert collect(d, {"spaces": [_sp("f2", "f2.png")]},
|
||||||
|
{"spaces": [_sp("f2", None)]}) == 0
|
||||||
|
assert (d / "f2.png").is_file(), "the editor says the file stays — it stays"
|
||||||
|
|
||||||
|
# 3. the space is deleted outright
|
||||||
|
seed("f3.png")
|
||||||
|
assert collect(d, {"spaces": [_sp("f3", "f3.png")]}, {"spaces": []}) == 0
|
||||||
|
assert (d / "f3.png").is_file()
|
||||||
|
|
||||||
|
# 4. the scheduled pass, later, still keeps both
|
||||||
|
cfg = {"spaces": [_sp("f2", None)]}
|
||||||
|
assert collect(d, cfg, cfg) == 0
|
||||||
|
assert (d / "f2.png").is_file() and (d / "f3.png").is_file()
|
||||||
|
|
||||||
|
# 5. an upload whose save was rejected is kept too, at any age. Ageing those
|
||||||
|
# out raced the retry: the sweep deleted a file the save was committing a
|
||||||
|
# reference to (caught by test_sweep_and_a_config_write_do_not_race).
|
||||||
|
seed("f4.current.png", "f4.reject.png")
|
||||||
|
live = {"spaces": [_sp("f4", "f4.current.png")]}
|
||||||
|
assert collect(d, live, live) == 0
|
||||||
|
assert (d / "f4.current.png").is_file() and (d / "f4.reject.png").is_file()
|
||||||
|
|
||||||
|
# 6. the same file still referenced by another space is never touched
|
||||||
|
seed("shared.png")
|
||||||
|
assert collect(d, {"spaces": [_sp("a", "shared.png"), _sp("b", "shared.png")]},
|
||||||
|
{"spaces": [_sp("a", None), _sp("b", "shared.png")]}) == 0
|
||||||
|
assert (d / "shared.png").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_collection_matrix(tmp_path):
|
||||||
|
"""Removing an attachment is a trash button; deleting the device is not."""
|
||||||
|
collect = plans.collect_attachments
|
||||||
|
|
||||||
|
def case(name):
|
||||||
|
d = tmp_path / name
|
||||||
|
d.mkdir()
|
||||||
|
return d
|
||||||
|
|
||||||
|
def seed(root, folder, fname, age=None):
|
||||||
|
(root / folder).mkdir(parents=True, exist_ok=True)
|
||||||
|
p = root / folder / fname
|
||||||
|
p.write_bytes(b"x")
|
||||||
|
if age:
|
||||||
|
_aged(p, age)
|
||||||
|
return p
|
||||||
|
|
||||||
|
def cfg(*markers):
|
||||||
|
return {"markers": [
|
||||||
|
{"id": mid, "pdfs": [{"url": f"/api/houseplan/content/files/{mid}/{n}"} for n in names]}
|
||||||
|
for mid, names in markers
|
||||||
|
]}
|
||||||
|
|
||||||
|
# 1. the user removed one attachment from a device that still exists
|
||||||
|
d = case("dropped")
|
||||||
|
seed(d, "m1", "dropped.pdf")
|
||||||
|
seed(d, "m1", "kept.pdf")
|
||||||
|
assert collect(d, cfg(("m1", ["dropped.pdf", "kept.pdf"])), cfg(("m1", ["kept.pdf"]))) == 1
|
||||||
|
assert not (d / "m1" / "dropped.pdf").exists()
|
||||||
|
assert (d / "m1" / "kept.pdf").is_file()
|
||||||
|
|
||||||
|
# 2. the device itself is gone: its manuals are not ours to throw away
|
||||||
|
d = case("device_gone")
|
||||||
|
seed(d, "m2", "manual.pdf", age=const.SCHEDULED_GRACE_S * 2)
|
||||||
|
assert collect(d, cfg(("m2", ["manual.pdf"])), cfg()) == 0
|
||||||
|
assert (d / "m2" / "manual.pdf").is_file()
|
||||||
|
# and the scheduled pass, later, agrees
|
||||||
|
assert collect(d, cfg(), cfg()) == 0
|
||||||
|
assert (d / "m2" / "manual.pdf").is_file()
|
||||||
|
|
||||||
|
# 3. a dialog that was never saved, in its own staging folder
|
||||||
|
d = case("staging")
|
||||||
|
seed(d, "up_x", "manual.pdf", age=const.PLAN_ORPHAN_TTL_S + 60)
|
||||||
|
assert collect(d, cfg(), cfg()) == 1
|
||||||
|
assert not (d / "up_x").exists()
|
||||||
|
|
||||||
|
# 4. an upload into a live device's folder whose save was rejected: kept,
|
||||||
|
# for the same reason as a plan's — a retry may be about to reference it
|
||||||
|
d = case("reject")
|
||||||
|
seed(d, "m3", "current.pdf")
|
||||||
|
seed(d, "m3", "rejected.pdf", age=const.SCHEDULED_GRACE_S + 60)
|
||||||
|
live = cfg(("m3", ["current.pdf"]))
|
||||||
|
assert collect(d, live, live) == 0
|
||||||
|
assert (d / "m3" / "current.pdf").is_file()
|
||||||
|
assert (d / "m3" / "rejected.pdf").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_a_staging_folder_ages_out(tmp_path):
|
||||||
|
"""The one age rule left. Everything else waits for the user to say so."""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
files = tmp_path / "files"
|
||||||
|
(files / "m1").mkdir(parents=True)
|
||||||
|
(files / "up_abandoned").mkdir(parents=True)
|
||||||
|
ancient = time.time() - const.SCHEDULED_GRACE_S * 12
|
||||||
|
hour_ago = time.time() - const.PLAN_ORPHAN_TTL_S - 60
|
||||||
|
|
||||||
|
for path, when in (
|
||||||
|
((files / "m1" / "ancient.pdf"), ancient),
|
||||||
|
((files / "up_abandoned" / "manual.pdf"), hour_ago),
|
||||||
|
):
|
||||||
|
path.write_bytes(b"x")
|
||||||
|
os.utime(path, (when, when))
|
||||||
|
|
||||||
|
cfg = {"markers": [{"id": "m1", "pdfs": []}]}
|
||||||
|
assert plans.collect_attachments(files, cfg, cfg) == 1
|
||||||
|
assert (files / "m1" / "ancient.pdf").is_file(), "age alone is never a reason"
|
||||||
|
assert not (files / "up_abandoned").exists(), "a cancelled dialog goes after an hour"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user