v1.59.0-rc.1: optimize plans and polish editor feedback
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 3m12s
Validate / backend (push) Failing after 8m53s
Validate / smoke (push) Failing after 13m51s

This commit is contained in:
Matysh
2026-08-06 10:14:52 +03:00
parent d2bec266ed
commit e0f6746d7f
43 changed files with 2052 additions and 568 deletions
+45 -2
View File
@@ -126,6 +126,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
cfg = stored.get("config")
lay_stored = await data.store.async_load() or {}
layout = lay_stored.get("layout") or {}
lay_meta = {
k: v for k, v in lay_stored.items()
if k not in ("layout", "rev", "geom_pending")
}
pending = {
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
}
@@ -134,14 +138,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
lay_rev = int(lay_stored.get("rev", 0))
if merged != pending: # 1. the durable intent, before anything moves
await data.store.async_save(
{"layout": layout, "rev": lay_rev, "geom_pending": merged}
{**lay_meta, "layout": layout, "rev": lay_rev, "geom_pending": merged}
)
rev = int(stored.get("rev", 0))
if cfg and migrate_config(cfg): # 2. the config half
rev += 1
await data.config_store.async_save({"config": cfg, "rev": rev})
migrate_layout(layout, merged) # 3. the layout half + intent cleared
await data.store.async_save({"layout": layout, "rev": lay_rev + 1})
await data.store.async_save({**lay_meta, "layout": layout, "rev": lay_rev + 1})
_LOGGER.info(
"House Plan: migrated %s space(s) to the square canvas", len(merged)
)
@@ -149,6 +153,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
# event must never see one migrated half and one old one
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
# Finish an explicit whole-plan optimization/undo interrupted between the
# config and layout store writes. The target was persisted before either
# visible half changed, so setup can always converge on the requested pair.
optimize_revs: tuple[int, int] | None = None
async with data.write_lock:
stored = await data.config_store.async_load() or {}
lay_stored = await data.store.async_load() or {}
pending = lay_stored.get("optimize_pending")
if isinstance(pending, dict) and isinstance(pending.get("config"), dict) \
and isinstance(pending.get("layout"), dict):
target_config = pending["config"]
target_layout = pending["layout"]
config_rev = int(stored.get("rev", 0))
layout_rev = int(lay_stored.get("rev", 0))
if stored.get("config") != target_config:
config_rev += 1
await data.config_store.async_save({
"config": target_config,
"rev": config_rev,
})
if lay_stored.get("layout", {}) != target_layout:
layout_rev += 1
layout_meta = {
k: v for k, v in lay_stored.items()
if k not in ("layout", "rev", "optimize_pending", "optimize_backup")
}
if not pending.get("clear_backup") and "optimize_backup" in lay_stored:
layout_meta["optimize_backup"] = lay_stored["optimize_backup"]
await data.store.async_save({
**layout_meta,
"layout": target_layout,
"rev": layout_rev,
})
optimize_revs = (config_rev, layout_rev)
_LOGGER.warning("House Plan: completed an interrupted plan optimization")
if optimize_revs is not None:
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
await async_check_plan_files(hass, entry)
# Scheduled collection of everything nobody ended up referencing.
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.59.0-beta.10"
VERSION = "1.59.0-rc.1"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.59.0-beta.10"
"version": "1.59.0-rc.1"
}
+25 -9
View File
@@ -350,6 +350,30 @@ DECOR_SCHEMA = vol.Any(
extra=vol.ALLOW_EXTRA),
)
def _wall_endpoints_pair(entry: dict) -> dict:
"""Exact wall endpoints are useful only as a complete a/b pair."""
if ("a" in entry) != ("b" in entry):
raise vol.Invalid("wall exact endpoints require both a and b")
return entry
WALL_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("key"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
# New writes retain exact normalized interval endpoints. The old
# key remains the compatibility lookup; endpoints preserve a
# differing-thickness breakpoint after a virtual span is closed.
vol.Optional("a"): vol.All([_NORM], vol.Length(min=2, max=2)),
vol.Optional("b"): vol.All([_NORM], vol.Length(min=2, max=2)),
},
extra=vol.ALLOW_EXTRA,
),
_wall_endpoints_pair,
)
SPACE_SCHEMA = vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Match(SPACE_ID_RE.pattern)),
@@ -402,15 +426,7 @@ SPACE_SCHEMA = vol.Schema(
# Wall thickness (docs/WALL-THICKNESS.md): keyed by a segment identity
# (midpoint + direction), thickness always in centimetres. Optional —
# a space without `walls` validates and renders exactly as before.
vol.Optional("walls"): vol.All([
vol.Schema(
{
vol.Required("key"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
},
extra=vol.ALLOW_EXTRA,
)
], vol.Length(max=MAX_WALLS)),
vol.Optional("walls"): vol.All([WALL_SCHEMA], vol.Length(max=MAX_WALLS)),
# Open (virtual) wall stretches: a piece of a shared boundary that the
# user opened. Optional and bounded — a space without `open_spans`
# validates exactly as before, and the legacy `rooms[].open_to` index
+229 -4
View File
@@ -7,6 +7,7 @@ import base64
import binascii
import json
import secrets
import time
from pathlib import Path
from typing import Any
@@ -36,6 +37,36 @@ from .validation import (
_LOGGER = logging.getLogger(__name__)
_OPTIMIZE_BACKUP = "optimize_backup"
_OPTIMIZE_PENDING = "optimize_pending"
def _optimizer_backup_is_current(config_data: dict[str, Any], layout_data: dict[str, Any]) -> bool:
"""An optimization can be undone only before any later plan edit."""
backup = layout_data.get(_OPTIMIZE_BACKUP)
if not isinstance(backup, dict):
return False
try:
return (
int(backup.get("after_config_rev", -1)) == int(config_data.get("rev", 0))
and int(backup.get("after_layout_rev", -1)) == int(layout_data.get("rev", 0))
)
except (TypeError, ValueError):
return False
async def _discard_optimizer_snapshot(rt: HouseplanData) -> None:
"""Free a snapshot made stale by a later ordinary config edit."""
data = await rt.store.async_load() or {}
if _OPTIMIZE_BACKUP not in data and _OPTIMIZE_PENDING not in data:
return
await rt.store.async_save({
**{
k: v for k, v in data.items()
if k not in (_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
}
})
@callback
def async_register(hass: HomeAssistant) -> None:
@@ -48,6 +79,8 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_layout_delete)
websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_optimize)
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_plans_list)
websocket_api.async_register_command(hass, ws_plans_delete)
@@ -85,8 +118,13 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
data = await rt.store.async_load() or {}
config_data = await rt.config_store.async_load() or {}
connection.send_result(
msg["id"], {"layout": data.get("layout", {}), "rev": int(data.get("rev", 0))}
msg["id"], {
"layout": data.get("layout", {}),
"rev": int(data.get("rev", 0)),
"can_optimize_undo": _optimizer_backup_is_current(config_data, data),
}
)
@@ -121,7 +159,8 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
return
new_rev = current_rev + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"layout": msg["layout"], "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -151,7 +190,8 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
# optimistic locking on layout/set meaningless — every drag reset the
# counter to 0 (HP-1454-08)
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"layout": layout, "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -548,7 +588,8 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
if msg["device_id"] in layout:
del layout[msg["device_id"]]
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"layout": layout, "rev": new_rev})
if new_rev is not None:
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
@@ -571,6 +612,7 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
connection.send_result(
msg["id"],
@@ -578,6 +620,7 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
"config": config,
"rev": data.get("rev", 0),
"can_write": may_write(hass, getattr(connection, "user", None)),
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
},
)
@@ -691,6 +734,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
return
new_rev = current_rev + 1
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
try:
await _discard_optimizer_snapshot(rt)
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
_LOGGER.exception("House Plan: discarding stale optimization backup failed")
# Still holding the lock: the file system is not part of the store's
# transaction, so collection has to be pinned to this commit (R3-1).
# It is best-effort housekeeping behind an already durable write — a
@@ -716,6 +763,184 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
# ---------------- whole-plan maintenance ----------------
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/optimize",
vol.Required("config"): CONFIG_SCHEMA,
vol.Required("layout"): LAYOUT_SCHEMA,
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
}
)
@websocket_api.async_response
async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Atomically-intended replacement of config+layout with one-deep undo.
Home Assistant stores are separate files, so a literal cross-file
transaction is impossible. Persisting the target as an intent before
either half changes makes a crash resumable during the next setup; the UI
only receives success once both halves are durable.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may optimize plans")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
size = len(json.dumps(msg["config"], separators=(",", ":")))
if size > MAX_CONFIG_BYTES:
connection.send_error(
msg["id"], "too_large",
f"Configuration is {size // 1024} KB, the limit is {MAX_CONFIG_BYTES // 1024} KB",
)
return
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
connection.send_error(
msg["id"], "conflict",
f"Plan changed elsewhere (config {config_rev}, layout {layout_rev})",
)
return
missing = await hass.async_add_executor_job(
_missing_internal_plans,
Path(hass.config.path(PLANS_DIR)),
msg["config"],
config_data.get("config"),
)
if missing:
connection.send_error(
msg["id"], "missing_plan",
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
return
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
backup = {
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout", {}),
"created": int(time.time()),
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
}
pending = {
"config": msg["config"],
"layout": msg["layout"],
"clear_backup": False,
}
layout_meta = {
k: v for k, v in layout_data.items()
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
}
# Intent first. A setup-time finisher completes whichever half a crash
# interrupted; until then the visible layout/revision remain unchanged.
await rt.store.async_save({
**layout_meta,
"layout": layout_data.get("layout", {}),
"rev": layout_rev,
_OPTIMIZE_BACKUP: backup,
_OPTIMIZE_PENDING: pending,
})
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
await rt.store.async_save({
**layout_meta,
"layout": msg["layout"],
"rev": new_layout_rev,
_OPTIMIZE_BACKUP: backup,
})
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
connection.send_result(msg["id"], {
"ok": True,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"can_undo": True,
})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/optimize_undo",
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
}
)
@websocket_api.async_response
async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Restore the snapshot, but never overwrite edits made after optimization."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may undo optimization")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
connection.send_error(msg["id"], "conflict", "Plan changed elsewhere")
return
if not _optimizer_backup_is_current(config_data, layout_data):
connection.send_error(
msg["id"], "no_backup",
"The optimization backup is unavailable or a later edit made it stale",
)
return
backup = layout_data[_OPTIMIZE_BACKUP]
restored_config = backup.get("config") or DEFAULT_CONFIG
restored_layout = backup.get("layout") or {}
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
pending = {
"config": restored_config,
"layout": restored_layout,
"clear_backup": True,
}
layout_meta = {
k: v for k, v in layout_data.items()
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
}
await rt.store.async_save({
**layout_meta,
"layout": layout_data.get("layout", {}),
"rev": layout_rev,
_OPTIMIZE_BACKUP: backup,
_OPTIMIZE_PENDING: pending,
})
await rt.config_store.async_save({"config": restored_config, "rev": new_config_rev})
await rt.store.async_save({
**layout_meta,
"layout": restored_layout,
"rev": new_layout_rev,
})
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
connection.send_result(msg["id"], {
"ok": True,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"can_undo": False,
})
# ---------------- plan uploads ----------------