feat v1.12.0: Quality Scale conformance (phases 7-8)

- entry.runtime_data (HouseplanData in store.py) instead of hass.data; WS answers
  not_ready without a loaded entry
- test-before-setup (ConfigEntryNotReady), async_unload_entry, async_remove_entry
  (Lovelace resource cleanup), single_config_entry in manifest
- Store minor_version + migration hook; diagnostics.py (redacted); repairs
  (broken_plan issues, en/ru); system_health.py; strings.json
- quality_scale.yaml self-assessment; HA-harness tests (config flow, WS, upload)
  in CI on py3.13; CI backend job updated
This commit is contained in:
Matysh
2026-07-06 00:27:47 +03:00
parent 8d9bf896e2
commit 42c24abcb4
25 changed files with 745 additions and 100 deletions
+111 -47
View File
@@ -1,13 +1,13 @@
"""House Plan: server-side house plan configuration + serving the Lovelace card."""
"""House Plan: server-side house plan configuration + Lovelace card serving."""
from __future__ import annotations
import logging
from pathlib import Path
from homeassistant.components.frontend import add_extra_js_url
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import issue_registry as ir
from . import websocket_api as hp_ws
from .const import (
@@ -17,20 +17,16 @@ from .const import (
FRONTEND_URL,
PLANS_DIR,
PLANS_URL,
STORAGE_CONFIG_KEY,
STORAGE_KEY,
STORAGE_VERSION,
VERSION,
)
from .store import HouseplanConfigEntry, create_data
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config) -> bool:
"""Register WS commands, the HTTP upload and the stores on startup."""
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN]["store"] = Store(hass, STORAGE_VERSION, STORAGE_KEY)
hass.data[DOMAIN]["config_store"] = Store(hass, STORAGE_VERSION, STORAGE_CONFIG_KEY)
hp_ws.async_register(hass)
from .http_api import HouseplanUploadView
@@ -38,11 +34,16 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Config entry: static frontend and plan files + auto-registration of the JS."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN]["entry"] = entry
entry.async_on_unload(entry.add_update_listener(_update_listener))
async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
"""Config entry: stores in runtime_data, static paths, card auto-registration."""
data = create_data(hass)
# test-before-setup: storage must be readable, otherwise retry later
try:
await data.store.async_load()
await data.config_store.async_load()
except Exception as err: # noqa: BLE001 — corrupt/unreadable .storage
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
entry.runtime_data = data
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
plans_path = Path(hass.config.path(PLANS_DIR))
@@ -51,46 +52,118 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
lambda: (plans_path.mkdir(parents=True, exist_ok=True), files_path.mkdir(parents=True, exist_ok=True))
)
static_paths = []
try:
from homeassistant.components.http import StaticPathConfig
# Static paths cannot be unregistered — register once per HA run.
if not hass.data[DOMAIN].get("static_registered"):
hass.data[DOMAIN]["static_registered"] = True
static_paths = []
try:
from homeassistant.components.http import StaticPathConfig
if card_path.exists():
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True))
static_paths.append(StaticPathConfig(FILES_URL, str(files_path), cache_headers=True))
await hass.http.async_register_static_paths(static_paths)
except ImportError: # older HA versions
if card_path.exists():
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True)
hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True)
if card_path.exists():
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True))
static_paths.append(StaticPathConfig(FILES_URL, str(files_path), cache_headers=True))
await hass.http.async_register_static_paths(static_paths)
except ImportError: # very old HA versions
if card_path.exists():
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True)
hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True)
if not card_path.exists():
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
return True
# Register the card. Preferably as a Lovelace resource (the frontend WAITS for those
# before rendering dashboards, so the card is available even on a cold start of the mobile
# app). If the resource registry is unavailable (Lovelace YAML mode, older versions) —
# fall back to extra_module_url.
# Register the card. Preferably as a Lovelace resource (the frontend AWAITS
# resources before rendering dashboards, so the card is available even on a cold
# start of the mobile app). If the resource registry is unavailable (YAML-mode
# Lovelace, old versions) — fall back to extra_module_url.
module_url = f"{FRONTEND_URL}?v={VERSION}"
if not await _register_lovelace_resource(hass, module_url):
add_extra_js_url(hass, module_url)
await _check_plan_files(hass, entry)
return True
async def _check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntry) -> None:
"""Repairs: raise an issue for every space whose plan file is missing on disk."""
cfg_raw = await entry.runtime_data.config_store.async_load() or {}
spaces = cfg_raw.get("config", {}).get("spaces", [])
plans_dir = Path(hass.config.path(PLANS_DIR))
def _missing() -> list[tuple[str, str]]:
res = []
for sp in spaces:
url = sp.get("plan_url") or ""
if not url.startswith(PLANS_URL + "/"):
continue # external/legacy URL — not ours to verify
fname = url[len(PLANS_URL) + 1 :].split("?", 1)[0]
if not (plans_dir / fname).is_file():
res.append((sp.get("id", "?"), fname))
return res
missing = await hass.async_add_executor_job(_missing)
seen = set()
for space_id, fname in missing:
seen.add(space_id)
ir.async_create_issue(
hass,
DOMAIN,
f"broken_plan_{space_id}",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="broken_plan",
translation_placeholders={"space": space_id, "file": fname},
)
# clear stale issues for spaces that are fine again (or gone)
for sp in spaces:
sid = sp.get("id", "?")
if sid not in seen:
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
"""Unload the entry.
WS commands and the HTTP view are global (async_setup) and stay registered —
their handlers resolve runtime data per call and answer `not_ready` while no
entry is loaded. Static paths cannot be unregistered by design.
"""
return True
async def async_remove_entry(hass: HomeAssistant, entry) -> None:
"""Clean up on integration removal: drop our Lovelace resource entry."""
try:
resources = _lovelace_resources(hass)
if resources is None or not hasattr(resources, "async_delete_item"):
return
for item in list(resources.async_items()):
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL:
await resources.async_delete_item(item["id"])
_LOGGER.debug("House Plan Lovelace resource removed: %s", item.get("url"))
except Exception as err: # noqa: BLE001 — best-effort cleanup
_LOGGER.debug("Could not remove the Lovelace resource on uninstall: %s", err)
def _lovelace_resources(hass: HomeAssistant):
lovelace = hass.data.get("lovelace")
resources = getattr(lovelace, "resources", None)
if resources is None and isinstance(lovelace, dict):
resources = lovelace.get("resources")
return resources
async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool:
"""Register (or update) the card in the Lovelace resource registry.
Returns True on success. Writes idempotently: if a resource with our path already exists —
update the URL on a version change; if absent — create it. Any-except → False (fallback to JS).
Returns True on success. Idempotent: if a resource with our path exists —
update the URL on version change; otherwise create it. Any exception → False
(fall back to extra_module_url).
"""
try:
lovelace = hass.data.get("lovelace")
resources = getattr(lovelace, "resources", None)
if resources is None and isinstance(lovelace, dict):
resources = lovelace.get("resources")
resources = _lovelace_resources(hass)
if resources is None:
return False
# the resource registry must be loaded
@@ -116,14 +189,5 @@ async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> b
_LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url)
return True
except Exception as err: # noqa: BLE001 — any failure → fallback
_LOGGER.debug("Failed to register the Lovelace resource (%s), falling back to extra_module_url", err)
_LOGGER.debug("Could not register the Lovelace resource (%s), falling back to extra_module_url", err)
return False
async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
hass.data[DOMAIN]["entry"] = entry
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN].pop("entry", None)
return True
@@ -14,8 +14,6 @@ class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
async def async_step_user(self, user_input=None):
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
if user_input is not None:
return self.async_create_entry(title="House Plan", data={}, options=user_input)
return self.async_show_form(
+2 -1
View File
@@ -4,13 +4,14 @@ DOMAIN = "houseplan"
STORAGE_KEY = f"{DOMAIN}.layout"
STORAGE_CONFIG_KEY = f"{DOMAIN}.config"
STORAGE_VERSION = 1
STORAGE_MINOR_VERSION = 1
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
PLANS_URL = "/houseplan_files/plans"
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.11.2"
VERSION = "1.12.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
@@ -0,0 +1,41 @@
"""Diagnostics for House Plan (Settings → ... → Download diagnostics)."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .store import HouseplanConfigEntry
# Marker metadata may contain personal notes, external links and manual filenames.
TO_REDACT = {"link", "description", "pdfs", "name"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: HouseplanConfigEntry
) -> dict[str, Any]:
"""Return a redacted dump of the stores."""
data = entry.runtime_data
cfg_raw = await data.config_store.async_load() or {}
layout_raw = await data.store.async_load() or {}
config = cfg_raw.get("config", {})
layout = layout_raw.get("layout", {})
return {
"options": dict(entry.options),
"rev": cfg_raw.get("rev", 0),
"spaces": [
{
"id": s.get("id"),
"aspect": s.get("aspect"),
"has_plan": bool(s.get("plan_url")),
"rooms": len(s.get("rooms", [])),
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
"segments": len(s.get("segments", [])),
}
for s in config.get("spaces", [])
],
"markers": async_redact_data(config.get("markers", []), TO_REDACT),
"settings": config.get("settings", {}),
"layout_entries": len(layout),
}
@@ -1042,4 +1042,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
</button>
</div>
</div>
</div>`}}Nt.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},_markup:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},Nt.styles=Pt,customElements.get("houseplan-card")||customElements.define("houseplan-card",Nt),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.11.2 ","background:#3ea6ff;color:#04121f;font-weight:700","");
</div>`}}Nt.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},_markup:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},Nt.styles=Pt,customElements.get("houseplan-card")||customElements.define("houseplan-card",Nt),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.12.0 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+3 -2
View File
@@ -18,7 +18,8 @@ except ImportError: # older HA versions
KEY_HASS = "hass" # type: ignore[assignment]
from homeassistant.core import HomeAssistant
from .const import CONF_ADMIN_ONLY, DOMAIN, FILES_DIR, FILES_URL
from .const import CONF_ADMIN_ONLY, FILES_DIR, FILES_URL
from .store import get_entry
from .validation import (
FILE_EXTENSIONS,
MAX_FILE_BYTES,
@@ -41,7 +42,7 @@ class HouseplanUploadView(HomeAssistantView):
async def post(self, request: web.Request) -> web.Response:
hass: HomeAssistant = request.app[KEY_HASS]
entry = hass.data.get(DOMAIN, {}).get("entry")
entry = get_entry(hass)
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
if admin_only:
user = request.get("hass_user")
+3 -2
View File
@@ -15,5 +15,6 @@
"iot_class": "local_push",
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"version": "1.11.2"
}
"single_config_entry": true,
"version": "1.12.0"
}
@@ -0,0 +1,112 @@
# Integration Quality Scale self-assessment.
# Custom integrations are not formally graded (they sit in the "Custom" tier),
# but we track the official checklist here. done = implemented, exempt = not
# applicable with the reason.
rules:
# ---- Bronze ----
action-setup:
status: exempt
comment: The integration registers no service actions.
appropriate-polling:
status: exempt
comment: No polling — storage + WebSocket API + frontend serving only.
brands:
status: done
comment: Local brand images in custom_components/houseplan/brand/ (HA >=2026.3 mechanism).
common-modules:
status: done
comment: const.py, store.py (stores + runtime data), validation.py (pure schemas).
config-flow:
status: done
config-flow-test-coverage:
status: done
comment: tests_backend/test_config_flow.py (runs in CI on Python 3.13).
dependency-transparency:
status: done
comment: No external requirements.
docs-actions:
status: exempt
comment: No service actions.
docs-high-level-description:
status: done
comment: README.md.
docs-installation-instructions:
status: done
comment: README.md (HACS + manual).
docs-removal-instructions:
status: done
comment: README.md uninstall section.
entity-event-setup:
status: exempt
comment: No entities.
entity-unique-id:
status: exempt
comment: No entities.
has-entity-name:
status: exempt
comment: No entities.
runtime-data:
status: done
comment: entry.runtime_data holds HouseplanData (stores + write lock).
test-before-configure:
status: exempt
comment: No external device/service to validate during the flow.
test-before-setup:
status: done
comment: Storage load is verified in async_setup_entry (ConfigEntryNotReady on failure).
unique-config-entry:
status: done
comment: single_config_entry in manifest.
# ---- Silver ----
action-exceptions:
status: exempt
comment: No service actions; WS handlers reply with typed error codes.
config-entry-unloading:
status: done
comment: Unload supported; WS commands and static paths are global by design (documented in __init__).
docs-configuration-parameters:
status: done
comment: README documents the admin_only option and card options.
docs-installation-parameters:
status: done
entity-unavailable:
status: exempt
comment: No entities.
integration-owner:
status: done
log-when-unavailable:
status: exempt
comment: No external service.
parallel-updates:
status: exempt
comment: No entities/polling.
reauthentication-flow:
status: exempt
comment: No authentication against an external service.
test-coverage:
status: todo
comment: Backend covered by pure tests + HA-harness tests in CI; measuring >95% is planned.
# ---- Gold (selected; entity/device rules are exempt — no entities) ----
diagnostics:
status: done
comment: diagnostics.py with redaction of personal marker fields.
reconfiguration-flow:
status: exempt
comment: Nothing to reconfigure — no host/credentials; all data is edited in the card UI.
repair-issues:
status: done
comment: Missing plan files raise repair issues (translation_key broken_plan).
docs-troubleshooting:
status: todo
docs-examples:
status: todo
# ---- Platinum ----
strict-typing:
status: todo
comment: Python is annotated; mypy strict pass is planned.
async-dependency:
status: exempt
comment: No dependencies.
inject-websession:
status: exempt
comment: No outgoing HTTP.
+69
View File
@@ -0,0 +1,69 @@
"""Storage helpers: versioned stores and per-entry runtime data."""
from __future__ import annotations
import asyncio
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)
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
+24
View File
@@ -0,0 +1,24 @@
{
"config": {
"step": {
"user": {
"title": "House Plan",
"data": { "admin_only": "Only administrators may edit the layout" }
}
},
"abort": { "single_instance_allowed": "Already configured — only one entry is allowed." }
},
"options": {
"step": {
"init": {
"data": { "admin_only": "Only administrators may edit the layout" }
}
}
},
"issues": {
"broken_plan": {
"title": "Floor plan image is missing",
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
}
}
}
@@ -0,0 +1,32 @@
"""System health for House Plan (Settings → System → Repairs → System information)."""
from __future__ import annotations
from typing import Any
from homeassistant.components import system_health
from homeassistant.core import HomeAssistant, callback
from .store import get_data
@callback
def async_register(hass: HomeAssistant, register: system_health.SystemHealthRegistration) -> None:
"""Register the system health info callback."""
register.async_register_info(system_health_info)
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"""Return integration health info."""
data = get_data(hass)
if data is None:
return {"status": "not set up"}
cfg_raw = await data.config_store.async_load() or {}
layout_raw = await data.store.async_load() or {}
config = cfg_raw.get("config", {})
return {
"config_rev": cfg_raw.get("rev", 0),
"spaces": len(config.get("spaces", [])),
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
"markers": len(config.get("markers", [])),
"layout_entries": len(layout_raw.get("layout", {})),
}
@@ -3,16 +3,28 @@
"step": {
"user": {
"title": "House Plan",
"data": { "admin_only": "Only administrators may edit the layout" }
"data": {
"admin_only": "Only administrators may edit the layout"
}
}
},
"abort": { "single_instance_allowed": "Already configured — only one entry is allowed." }
"abort": {
"single_instance_allowed": "Already configured — only one entry is allowed."
}
},
"options": {
"step": {
"init": {
"data": { "admin_only": "Only administrators may edit the layout" }
"data": {
"admin_only": "Only administrators may edit the layout"
}
}
}
},
"issues": {
"broken_plan": {
"title": "Floor plan image is missing",
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
}
}
}
@@ -3,16 +3,28 @@
"step": {
"user": {
"title": "House Plan",
"data": { "admin_only": "Правка раскладки только администраторами" }
"data": {
"admin_only": "Правка раскладки только администраторами"
}
}
},
"abort": { "single_instance_allowed": "Уже настроено — допускается одна запись." }
"abort": {
"single_instance_allowed": "Уже настроено — допускается одна запись."
}
},
"options": {
"step": {
"init": {
"data": { "admin_only": "Правка раскладки только администраторами" }
"data": {
"admin_only": "Правка раскладки только администраторами"
}
}
}
},
"issues": {
"broken_plan": {
"title": "Файл плана этажа не найден",
"description": "Файл плана `{file}` пространства `{space}` не найден в `config/houseplan/plans/`. Откройте настройки пространства в карточке House Plan и загрузите план заново."
}
}
}
+42 -31
View File
@@ -1,7 +1,6 @@
"""House Plan WS commands: layout, space configuration, plan uploads."""
from __future__ import annotations
import asyncio
import base64
import binascii
from pathlib import Path
@@ -13,9 +12,10 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from .const import (
CONF_ADMIN_ONLY, DEFAULT_CONFIG, DOMAIN,
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
PLANS_DIR, PLANS_URL,
)
from .store import HouseplanData, get_data, get_entry
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
@@ -34,25 +34,21 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_plan_set)
def _store(hass: HomeAssistant):
return hass.data[DOMAIN]["store"]
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
"""Runtime data of the loaded entry; answers `not_ready` when not set up.
def _config_store(hass: HomeAssistant):
return hass.data[DOMAIN]["config_store"]
def _write_lock(hass: HomeAssistant) -> asyncio.Lock:
"""A single lock over the load→modify→save cycle of both stores.
Without it, parallel WS calls lose changes (last-writer-wins),
The write_lock inside serializes every loadmodifysave cycle of both
stores: without it parallel WS calls lose changes (last-writer-wins)
and the expected_rev check is not atomic.
"""
return hass.data[DOMAIN].setdefault("write_lock", asyncio.Lock())
data = get_data(hass)
if data is None:
connection.send_error(msg_id, "not_ready", "House Plan is not set up")
return data
def _check_write(hass: HomeAssistant, connection) -> bool:
entry = hass.data[DOMAIN].get("entry")
entry = get_entry(hass)
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
return connection.user.is_admin if admin_only else True
@@ -64,7 +60,10 @@ def _check_write(hass: HomeAssistant, connection) -> bool:
@websocket_api.async_response
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Return the saved layout."""
data = await _store(hass).async_load() or {}
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
data = await rt.store.async_load() or {}
connection.send_result(msg["id"], {"layout": data.get("layout", {})})
@@ -77,8 +76,11 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
return
async with _write_lock(hass):
await _store(hass).async_save({"layout": msg["layout"]})
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
async with rt.write_lock:
await rt.store.async_save({"layout": msg["layout"]})
connection.send_result(msg["id"], {"ok": True})
@@ -95,12 +97,14 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
return
store = _store(hass)
async with _write_lock(hass):
data = await store.async_load() or {}
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
async with rt.write_lock:
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
layout[msg["device_id"]] = msg["pos"]
await store.async_save({"layout": layout})
await rt.store.async_save({"layout": layout})
connection.send_result(msg["id"], {"ok": True})
@@ -116,13 +120,15 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
return
store = _store(hass)
async with _write_lock(hass):
data = await store.async_load() or {}
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
async with rt.write_lock:
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
if msg["device_id"] in layout:
del layout[msg["device_id"]]
await store.async_save({"layout": layout})
await rt.store.async_save({"layout": layout})
connection.send_result(msg["id"], {"ok": True})
@@ -133,7 +139,10 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
@websocket_api.async_response
async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Return the configuration and its revision."""
data = await _config_store(hass).async_load() or {}
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
data = await rt.config_store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
@@ -156,9 +165,11 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the configuration")
return
store = _config_store(hass)
async with _write_lock(hass):
data = await store.async_load() or {}
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
async with rt.write_lock:
data = await rt.config_store.async_load() or {}
current_rev = data.get("rev", 0)
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
connection.send_error(
@@ -167,7 +178,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
return
new_rev = current_rev + 1
await store.async_save({"config": msg["config"], "rev": new_rev})
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})