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
+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 load→modify→save 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})