mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
Owner's batch (committed to dev earlier today, released here):
- devices count as content for the default zoom;
- the editor no longer shifts the plan — the stage measures its own top
instead of assuming 118px of header;
- zoom goes out to 0.4x, centred.
From the review:
- HP-1490-01: the square-canvas migration wrote two stores in sequence, and
the first write deleted the aspects the second needed — a crash between
them stranded the layout in the old coordinates with nothing able to
finish it. The intent {space: old aspect} is durable now: saved to the
layout store before anything moves, cleared by the same write that stores
the migrated layout, each half idempotent behind its own trigger. The
update event fires only after both halves are on disk. Proven at the exact
crash boundary by a harness test that fails the layout write once.
- HP-1490-02: check_quota and the file write were two executor jobs with
nothing between them, so N parallel uploads all measured the store before
any of them wrote. One job under a dedicated upload_lock now — narrower
than write_lock on purpose, a directory scan must not stall config saves.
A failed write reserves nothing.
- HP-1490-03: the content frame fed pan, zoom, clamp AND pointer maths, so
the editors were boxed into yesterday's drawing. Edit modes measure from
the full square; mode switches refit rather than carry a view clamped
against the wrong base.
- HP-1490-04: Save could outrun the proportions read and ship the previous
file's ratio. Picking a plan clears it immediately; Save awaits the
bounded read and stores 'unknown' over a lie.
- §5: package-lock version synced, duplicated comment removed.
New: smoke_audit_1490.mjs, migration crash-recovery pure + harness tests,
parallel-quota harness test. Inventory: 138 unit / 49 pure / 40 harness / 64
smokes.
82 lines
3.1 KiB
Python
82 lines
3.1 KiB
Python
"""Storage helpers: versioned stores and per-entry runtime data."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers.storage import Store
|
|
|
|
from .const import DOMAIN, STORAGE_CONFIG_KEY, STORAGE_KEY, STORAGE_MINOR_VERSION, STORAGE_VERSION
|
|
|
|
|
|
class HouseplanStore(Store):
|
|
"""Store with a migration hook.
|
|
|
|
Bump STORAGE_MINOR_VERSION for backward-compatible schema additions and
|
|
STORAGE_VERSION for breaking changes, then handle them here. Keeping the
|
|
skeleton in place from day one means old installations always pass through
|
|
a single, tested upgrade path.
|
|
"""
|
|
|
|
async def _async_migrate_func(
|
|
self,
|
|
old_major_version: int,
|
|
old_minor_version: int,
|
|
old_data: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
data = old_data
|
|
# if old_major_version == 1 and old_minor_version < 2:
|
|
# ...migrate...
|
|
return data
|
|
|
|
|
|
@dataclass
|
|
class HouseplanData:
|
|
"""Runtime data of the single config entry (entry.runtime_data)."""
|
|
|
|
store: HouseplanStore
|
|
config_store: HouseplanStore
|
|
# One lock for every load→modify→save cycle of both stores: prevents
|
|
# lost updates from concurrent WS calls and makes the rev check atomic.
|
|
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
# A separate, narrower lock for the check-quota→write-file pair of an
|
|
# upload. Without it N parallel uploads all measure the store BEFORE any
|
|
# of them writes, and all pass a quota only one of them fits under
|
|
# (HP-1490-02). Separate from write_lock so a slow directory scan does not
|
|
# stall config/layout commits.
|
|
upload_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]
|
|
|
|
|
|
def create_data(hass: HomeAssistant) -> HouseplanData:
|
|
"""Create the stores for a config entry."""
|
|
return HouseplanData(
|
|
store=HouseplanStore(hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_MINOR_VERSION),
|
|
config_store=HouseplanStore(
|
|
hass, STORAGE_VERSION, STORAGE_CONFIG_KEY, minor_version=STORAGE_MINOR_VERSION
|
|
),
|
|
)
|
|
|
|
|
|
def get_data(hass: HomeAssistant) -> HouseplanData | None:
|
|
"""Runtime data of the loaded entry, or None when not set up."""
|
|
entries = hass.config_entries.async_loaded_entries(DOMAIN)
|
|
return entries[0].runtime_data if entries else None
|
|
|
|
|
|
def get_entry(hass: HomeAssistant) -> ConfigEntry | None:
|
|
"""The loaded config entry, or None."""
|
|
entries = hass.config_entries.async_loaded_entries(DOMAIN)
|
|
return entries[0] if entries else None
|