mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
v1.50.1: the v1.50.0 review (HP-1500-01..03)
- HP-1500-02: the stage budget was the absolute document coordinate, so any tall dashboard content before the card was billed as header and the stage collapsed to 0px. Measure our own chrome relative to the card plus a bounded (<=120px) allowance for what the viewport keeps above us; re-measure on window resize, remove the listener in disconnectedCallback. - HP-1500-03, both layers: contentBounds opens a near-zero axis (< ~an icon) up to a 200-unit floor and ignores extra points outside a canvas envelope (-25%..125%) for FRAMING purposes only; the server bounds layout coordinates to +-4 — any finite float used to pass, and one 1e100 hid the plan from every viewer. A thin real room keeps its tight frame; the gate sensor past the edge still stretches it. - HP-1500-01: no automatic double-transform — a correct layout and a stranded one are indistinguishable, and guessing wrong corrupts good data. Explicit admin command houseplan/geometry/repair: dry_run previews, the backup rides the same store write, undo restores, and routine layout writes now preserve unrelated store keys instead of eating the backup. Tests: contentBounds guards (unit), layout coordinate bounds + repair lifecycle (harness), card-below-content smoke. Inventory: 139 / 49 / 42 / 64.
This commit is contained in:
@@ -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.50.0"
|
||||
VERSION = "1.50.1"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.50.0"
|
||||
"version": "1.50.1"
|
||||
}
|
||||
|
||||
@@ -95,8 +95,14 @@ _TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
|
||||
_TEXT_OR_NONE = vol.Any(None, _TEXT)
|
||||
_URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
|
||||
# Positions are normalised to the canvas (0..1). Allow generous slack for an
|
||||
# icon dragged past an edge, but not arbitrary magnitudes: any finite float
|
||||
# used to pass, and a single stored 1e100 stretched every client's view of the
|
||||
# space until the plan was invisible (HP-1500-03).
|
||||
_COORD = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): _finite, vol.Required("y"): _finite},
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
@@ -41,6 +41,7 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
"""Register the WS commands."""
|
||||
websocket_api.async_register_command(hass, ws_layout_get)
|
||||
websocket_api.async_register_command(hass, ws_layout_set)
|
||||
websocket_api.async_register_command(hass, ws_geometry_repair)
|
||||
websocket_api.async_register_command(hass, ws_layout_update)
|
||||
websocket_api.async_register_command(hass, ws_layout_delete)
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
@@ -118,7 +119,8 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({"layout": msg["layout"], "rev": new_rev})
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"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})
|
||||
|
||||
@@ -147,11 +149,90 @@ 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({"layout": layout, "rev": new_rev})
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"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})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/geometry/repair",
|
||||
vol.Required("space_id"): str,
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Optional("dry_run"): bool,
|
||||
vol.Optional("undo"): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Re-apply the square-canvas transform to ONE space's layout, explicitly.
|
||||
|
||||
For installations that hit the v1.48/v1.49 crash window: the config write
|
||||
of the migration landed, the layout write did not, and the trigger fields
|
||||
were already gone — markers and labels of that space are stranded in the
|
||||
old coordinates with nothing able to tell (HP-1500-01). Nothing can be
|
||||
detected reliably after the fact, and re-running a transform on a layout
|
||||
that is already correct would corrupt it, so this NEVER runs by itself:
|
||||
an administrator names the space and its old aspect, may preview with
|
||||
`dry_run`, and gets a one-deep backup written in the same store write —
|
||||
`undo` restores it.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may repair the layout")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
from .geometry_migration import migrate_layout
|
||||
|
||||
space_id = msg["space_id"]
|
||||
if not valid_space_id(space_id):
|
||||
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout") or {}
|
||||
current_rev = int(data.get("rev", 0))
|
||||
if msg.get("undo"):
|
||||
backup = data.get("repair_backup")
|
||||
if not isinstance(backup, dict) or backup.get("space") != space_id:
|
||||
connection.send_error(msg["id"], "no_backup", "No repair backup stored for this space")
|
||||
return
|
||||
restored = dict(layout)
|
||||
for key, pos in (backup.get("positions") or {}).items():
|
||||
restored[key] = pos
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({"layout": restored, "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev,
|
||||
"restored": len(backup.get("positions") or {})})
|
||||
return
|
||||
touched = {
|
||||
k: dict(v) for k, v in layout.items()
|
||||
if isinstance(v, dict) and str(v.get("s")) == space_id
|
||||
}
|
||||
preview = {k: dict(v) for k, v in touched.items()}
|
||||
migrate_layout(preview, {space_id: msg["aspect"]})
|
||||
if msg.get("dry_run"):
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True, "dry_run": True, "moved": len(preview),
|
||||
"before": touched, "after": preview,
|
||||
})
|
||||
return
|
||||
new_layout = {**layout, **preview}
|
||||
new_rev = current_rev + 1
|
||||
# the backup rides the same store write: either both are durable or
|
||||
# neither — the deletion-shy rules of this project apply to positions
|
||||
# too
|
||||
await rt.store.async_save({
|
||||
"layout": new_layout, "rev": new_rev,
|
||||
"repair_backup": {"space": space_id, "positions": touched},
|
||||
})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "moved": len(preview)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/files/migrate",
|
||||
@@ -455,7 +536,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({"layout": layout, "rev": new_rev})
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"layout": layout, "rev": new_rev})
|
||||
if new_rev is not None:
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
Reference in New Issue
Block a user