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
+3 -3
View File
@@ -39,7 +39,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: { python-version: "3.12" } with: { python-version: "3.13" }
- run: pip install pytest voluptuous - run: pip install pytest voluptuous pytest-homeassistant-custom-component
- name: Backend unit tests - name: Backend unit tests (pure + HA harness)
run: python -m pytest tests_backend/ -q run: python -m pytest tests_backend/ -q
+99 -35
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 from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from homeassistant.components.frontend import add_extra_js_url from homeassistant.components.frontend import add_extra_js_url
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant 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 . import websocket_api as hp_ws
from .const import ( from .const import (
@@ -17,20 +17,16 @@ from .const import (
FRONTEND_URL, FRONTEND_URL,
PLANS_DIR, PLANS_DIR,
PLANS_URL, PLANS_URL,
STORAGE_CONFIG_KEY,
STORAGE_KEY,
STORAGE_VERSION,
VERSION, VERSION,
) )
from .store import HouseplanConfigEntry, create_data
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config) -> bool: 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.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) hp_ws.async_register(hass)
from .http_api import HouseplanUploadView from .http_api import HouseplanUploadView
@@ -38,11 +34,16 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
return True return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
"""Config entry: static frontend and plan files + auto-registration of the JS.""" """Config entry: stores in runtime_data, static paths, card auto-registration."""
hass.data.setdefault(DOMAIN, {}) data = create_data(hass)
hass.data[DOMAIN]["entry"] = entry # test-before-setup: storage must be readable, otherwise retry later
entry.async_on_unload(entry.add_update_listener(_update_listener)) 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" card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
plans_path = Path(hass.config.path(PLANS_DIR)) plans_path = Path(hass.config.path(PLANS_DIR))
@@ -51,6 +52,9 @@ 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)) lambda: (plans_path.mkdir(parents=True, exist_ok=True), files_path.mkdir(parents=True, exist_ok=True))
) )
# 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 = [] static_paths = []
try: try:
from homeassistant.components.http import StaticPathConfig from homeassistant.components.http import StaticPathConfig
@@ -60,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True)) static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True))
static_paths.append(StaticPathConfig(FILES_URL, str(files_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) await hass.http.async_register_static_paths(static_paths)
except ImportError: # older HA versions except ImportError: # very old HA versions
if card_path.exists(): if card_path.exists():
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False) 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(PLANS_URL, str(plans_path), cache_headers=True)
@@ -70,27 +74,96 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path) _LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
return True return True
# Register the card. Preferably as a Lovelace resource (the frontend WAITS for those # Register the card. Preferably as a Lovelace resource (the frontend AWAITS
# before rendering dashboards, so the card is available even on a cold start of the mobile # resources before rendering dashboards, so the card is available even on a cold
# app). If the resource registry is unavailable (Lovelace YAML mode, older versions) — # start of the mobile app). If the resource registry is unavailable (YAML-mode
# fall back to extra_module_url. # Lovelace, old versions) — fall back to extra_module_url.
module_url = f"{FRONTEND_URL}?v={VERSION}" module_url = f"{FRONTEND_URL}?v={VERSION}"
if not await _register_lovelace_resource(hass, module_url): if not await _register_lovelace_resource(hass, module_url):
add_extra_js_url(hass, module_url) add_extra_js_url(hass, module_url)
await _check_plan_files(hass, entry)
return True 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: async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool:
"""Register (or update) the card in the Lovelace resource registry. """Register (or update) the card in the Lovelace resource registry.
Returns True on success. Writes idempotently: if a resource with our path already exists — Returns True on success. Idempotent: if a resource with our path exists —
update the URL on a version change; if absent — create it. Any-except → False (fallback to JS). update the URL on version change; otherwise create it. Any exception → False
(fall back to extra_module_url).
""" """
try: try:
lovelace = hass.data.get("lovelace") resources = _lovelace_resources(hass)
resources = getattr(lovelace, "resources", None)
if resources is None and isinstance(lovelace, dict):
resources = lovelace.get("resources")
if resources is None: if resources is None:
return False return False
# the resource registry must be loaded # 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) _LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url)
return True return True
except Exception as err: # noqa: BLE001 — any failure → fallback 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 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 VERSION = 1
async def async_step_user(self, user_input=None): 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: if user_input is not None:
return self.async_create_entry(title="House Plan", data={}, options=user_input) return self.async_create_entry(title="House Plan", data={}, options=user_input)
return self.async_show_form( return self.async_show_form(
+2 -1
View File
@@ -4,13 +4,14 @@ DOMAIN = "houseplan"
STORAGE_KEY = f"{DOMAIN}.layout" STORAGE_KEY = f"{DOMAIN}.layout"
STORAGE_CONFIG_KEY = f"{DOMAIN}.config" STORAGE_CONFIG_KEY = f"{DOMAIN}.config"
STORAGE_VERSION = 1 STORAGE_VERSION = 1
STORAGE_MINOR_VERSION = 1
FRONTEND_URL = "/houseplan_files/houseplan-card.js" FRONTEND_URL = "/houseplan_files/houseplan-card.js"
PLANS_URL = "/houseplan_files/plans" PLANS_URL = "/houseplan_files/plans"
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files" FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.11.2" VERSION = "1.12.0"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "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> </button>
</div> </div>
</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] KEY_HASS = "hass" # type: ignore[assignment]
from homeassistant.core import HomeAssistant 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 ( from .validation import (
FILE_EXTENSIONS, FILE_EXTENSIONS,
MAX_FILE_BYTES, MAX_FILE_BYTES,
@@ -41,7 +42,7 @@ class HouseplanUploadView(HomeAssistantView):
async def post(self, request: web.Request) -> web.Response: async def post(self, request: web.Request) -> web.Response:
hass: HomeAssistant = request.app[KEY_HASS] 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)) admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
if admin_only: if admin_only:
user = request.get("hass_user") user = request.get("hass_user")
+2 -1
View File
@@ -15,5 +15,6 @@
"iot_class": "local_push", "iot_class": "local_push",
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "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": { "step": {
"user": { "user": {
"title": "House Plan", "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": { "options": {
"step": { "step": {
"init": { "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": { "step": {
"user": { "user": {
"title": "House Plan", "title": "House Plan",
"data": { "admin_only": "Правка раскладки только администраторами" } "data": {
"admin_only": "Правка раскладки только администраторами"
}
} }
}, },
"abort": { "single_instance_allowed": "Уже настроено — допускается одна запись." } "abort": {
"single_instance_allowed": "Уже настроено — допускается одна запись."
}
}, },
"options": { "options": {
"step": { "step": {
"init": { "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.""" """House Plan WS commands: layout, space configuration, plan uploads."""
from __future__ import annotations from __future__ import annotations
import asyncio
import base64 import base64
import binascii import binascii
from pathlib import Path from pathlib import Path
@@ -13,9 +12,10 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from .const import ( from .const import (
CONF_ADMIN_ONLY, DEFAULT_CONFIG, DOMAIN, CONF_ADMIN_ONLY, DEFAULT_CONFIG,
PLANS_DIR, PLANS_URL, PLANS_DIR, PLANS_URL,
) )
from .store import HouseplanData, get_data, get_entry
from .validation import ( from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES, CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id, 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) websocket_api.async_register_command(hass, ws_plan_set)
def _store(hass: HomeAssistant): def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
return hass.data[DOMAIN]["store"] """Runtime data of the loaded entry; answers `not_ready` when not set up.
The write_lock inside serializes every loadmodifysave cycle of both
def _config_store(hass: HomeAssistant): stores: without it parallel WS calls lose changes (last-writer-wins)
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),
and the expected_rev check is not atomic. 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: 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)) admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
return connection.user.is_admin if admin_only else True 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 @websocket_api.async_response
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Return the saved layout.""" """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", {})}) 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): if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout") connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
return return
async with _write_lock(hass): rt = _runtime(hass, connection, msg["id"])
await _store(hass).async_save({"layout": msg["layout"]}) 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}) 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): if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout") connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
return return
store = _store(hass) rt = _runtime(hass, connection, msg["id"])
async with _write_lock(hass): if rt is None:
data = await store.async_load() or {} return
async with rt.write_lock:
data = await rt.store.async_load() or {}
layout = data.get("layout", {}) layout = data.get("layout", {})
layout[msg["device_id"]] = msg["pos"] 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}) 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): if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout") connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
return return
store = _store(hass) rt = _runtime(hass, connection, msg["id"])
async with _write_lock(hass): if rt is None:
data = await store.async_load() or {} return
async with rt.write_lock:
data = await rt.store.async_load() or {}
layout = data.get("layout", {}) layout = data.get("layout", {})
if msg["device_id"] in layout: if msg["device_id"] in layout:
del layout[msg["device_id"]] 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}) 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 @websocket_api.async_response
async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Return the configuration and its revision.""" """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", {})} config = {**DEFAULT_CONFIG, **data.get("config", {})}
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)}) 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): if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the configuration") connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the configuration")
return return
store = _config_store(hass) rt = _runtime(hass, connection, msg["id"])
async with _write_lock(hass): if rt is None:
data = await store.async_load() or {} return
async with rt.write_lock:
data = await rt.config_store.async_load() or {}
current_rev = data.get("rev", 0) current_rev = data.get("rev", 0)
if "expected_rev" in msg and msg["expected_rev"] != current_rev: if "expected_rev" in msg and msg["expected_rev"] != current_rev:
connection.send_error( connection.send_error(
@@ -167,7 +178,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
) )
return return
new_rev = current_rev + 1 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}) hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev}) connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
+1 -1
View File
@@ -1042,4 +1042,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
</button> </button>
</div> </div>
</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","");
+24
View File
@@ -1,5 +1,29 @@
# Changelog # Changelog
## v1.12.0 — 2026-07-05 (Quality Scale: Bronze + selected Silver/Gold)
Backend brought to Integration Quality Scale patterns (custom integrations are not
formally graded; progress is tracked in `custom_components/houseplan/quality_scale.yaml`):
- **`entry.runtime_data`** (typed `HouseplanData`: both stores + the write lock) replaces
`hass.data[DOMAIN]` for entry data; WS handlers resolve it per call and answer
`not_ready` while no entry is loaded. New `store.py` common module.
- **test-before-setup**: storage readability is verified in `async_setup_entry`
(`ConfigEntryNotReady` on failure). **Unloading** is supported; WS commands and
static paths are global by design (documented).
- **`single_config_entry: true`** in the manifest replaces the manual flow check.
- **Store versioning**: stores now carry `minor_version` and a migration hook
(`HouseplanStore._async_migrate_func`) — schema changes get a single upgrade path.
- **Diagnostics** (`diagnostics.py`): redacted dump (options, rev, per-space stats,
markers with personal fields redacted, layout size).
- **Repairs**: a missing plan file raises a repair issue (`broken_plan`) with
en/ru translations; issues clear automatically when resolved.
- **System health** (`system_health.py`): rev, spaces/rooms/markers/layout counters.
- **Uninstall cleanup**: `async_remove_entry` deletes our Lovelace resource entry.
- **Tests**: config flow, WS API (layout ops, rev conflict, not_ready, plan upload
validation), HTTP upload (ok/bad ext/traversal) on `pytest-homeassistant-custom-component`
— run in CI on Python 3.13; pure validation tests still run anywhere.
- `strings.json` added; translations updated.
## v1.11.2 — 2026-07-05 (device dialog: usable Description height) ## v1.11.2 — 2026-07-05 (device dialog: usable Description height)
- The Description textarea in the device edit dialog was squeezed to ~2 lines by - The Description textarea in the device edit dialog was squeezed to ~2 lines by
the dialog body's flex column. Now `min-height: 92px`, `flex-shrink: 0`, `rows=4`; the dialog body's flex column. Now `min-height: 92px`, `flex-shrink: 0`, `rows=4`;
+5 -2
View File
@@ -13,14 +13,14 @@
| Item | State | | Item | State |
|---|---| |---|---|
| Version | **v1.11.2** everywhere (manifest, const.py, package.json, CARD_VERSION) | | Version | **v1.12.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 | | GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) | | CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/8995** (queue ≈2 months as of 2026-07) | | HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/8995** (queue ≈2 months as of 2026-07) |
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations | | Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now | | Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) | | Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
| Tests | 15 frontend (node:test on logic/rules) + 10 backend (pytest on validation.py) | | Tests | 15 frontend (node:test) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13 + pytest-homeassistant-custom-component; skipped locally — sandbox has py3.10) |
## Recent milestones (details in CHANGELOG.md) ## Recent milestones (details in CHANGELOG.md)
@@ -32,6 +32,9 @@
- **v1.11.0** — full English translation + en/ru UI localization. - **v1.11.0** — full English translation + en/ru UI localization.
- **v1.11.1** — brand images inside the integration; CI fully green for the first time. - **v1.11.1** — brand images inside the integration; CI fully green for the first time.
- **v1.11.2** — Description textarea fix in the device dialog. - **v1.11.2** — Description textarea fix in the device dialog.
- **v1.12.0** — Quality Scale conformance: runtime_data, test-before-setup, unloading,
single_config_entry, Store migrations hook, diagnostics, repairs, system health,
uninstall cleanup, HA-harness tests in CI, quality_scale.yaml.
## Where things live ## Where things live
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.11.2", "version": "1.12.0",
"description": "Interactive house plan Lovelace card for Home Assistant", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+1 -1
View File
@@ -20,7 +20,7 @@ import './editor';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.11.2'; const CARD_VERSION = '1.12.0';
const LS_KEY = 'houseplan_card_layout_v1'; const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_ZOOM = 'houseplan_card_zoom_v1';
+19
View File
@@ -0,0 +1,19 @@
"""Shared fixtures. HA-harness tests are skipped when homeassistant is not installed
(the local sandbox has Python 3.10; CI runs them on 3.13 with
pytest-homeassistant-custom-component)."""
import pytest
try:
import homeassistant # noqa: F401
HAS_HA = True
except ImportError:
HAS_HA = False
collect_ignore_glob = [] if HAS_HA else ["test_ha_*.py"]
if HAS_HA:
@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations):
"""Allow loading custom_components in the test hass."""
yield
+50
View File
@@ -0,0 +1,50 @@
"""Config flow tests (run in CI with pytest-homeassistant-custom-component)."""
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from custom_components.houseplan.const import CONF_ADMIN_ONLY, DOMAIN
async def test_user_flow_creates_entry(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_ADMIN_ONLY: True}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "House Plan"
assert result["options"] == {CONF_ADMIN_ONLY: True}
async def test_single_instance(hass: HomeAssistant) -> None:
"""manifest single_config_entry must prevent a second entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.config_entries.flow.async_configure(result["flow_id"], user_input={})
result2 = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "single_instance_allowed"
async def test_options_flow(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input={})
entry = hass.config_entries.async_entries(DOMAIN)[0]
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={CONF_ADMIN_ONLY: True}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert entry.options == {CONF_ADMIN_ONLY: True}
+28
View File
@@ -0,0 +1,28 @@
"""Entry setup/unload tests (CI)."""
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.houseplan.const import DOMAIN
async def _setup(hass: HomeAssistant) -> MockConfigEntry:
entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={})
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry
async def test_setup_creates_runtime_data(hass: HomeAssistant) -> None:
entry = await _setup(hass)
assert entry.state.value == "loaded"
assert entry.runtime_data is not None
assert entry.runtime_data.store is not None
assert entry.runtime_data.config_store is not None
async def test_unload(hass: HomeAssistant) -> None:
entry = await _setup(hass)
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state.value == "not_loaded"
+49
View File
@@ -0,0 +1,49 @@
"""HTTP upload endpoint tests (CI)."""
from aiohttp import FormData
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from pytest_homeassistant_custom_component.typing import ClientSessionGenerator
from custom_components.houseplan.const import DOMAIN
async def _setup(hass: HomeAssistant) -> None:
entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={})
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
async def test_upload_ok(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None:
await _setup(hass)
client = await hass_client()
fd = FormData()
fd.add_field("marker_id", "m1")
fd.add_field("file", b"%PDF-1.4 test", filename="manual.pdf", content_type="application/pdf")
resp = await client.post("/api/houseplan/upload", data=fd)
assert resp.status == 200
body = await resp.json()
assert body["ok"] and body["url"].startswith("/houseplan_files/files/m1/manual.pdf?v=")
async def test_upload_bad_ext(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None:
await _setup(hass)
client = await hass_client()
fd = FormData()
fd.add_field("file", b"MZ", filename="evil.exe")
resp = await client.post("/api/houseplan/upload", data=fd)
assert resp.status == 400
assert (await resp.json())["error"] == "bad_ext"
async def test_upload_traversal_sanitized(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None:
await _setup(hass)
client = await hass_client()
fd = FormData()
fd.add_field("marker_id", "../../etc")
fd.add_field("file", b"x", filename="../..//passwd.txt")
resp = await client.post("/api/houseplan/upload", data=fd)
assert resp.status == 200
body = await resp.json()
# both the marker dir and the filename must be flattened to safe names
assert ".." not in body["url"]
+94
View File
@@ -0,0 +1,94 @@
"""WebSocket API tests (CI): layout ops, config rev conflict, not_ready gate."""
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from pytest_homeassistant_custom_component.typing import WebSocketGenerator
from custom_components.houseplan.const import DOMAIN
async def _setup(hass: HomeAssistant) -> MockConfigEntry:
entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={})
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry
async def test_layout_roundtrip(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None:
await _setup(hass)
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "houseplan/layout/get"})
resp = await client.receive_json()
assert resp["success"] and resp["result"]["layout"] == {}
await client.send_json_auto_id(
{"type": "houseplan/layout/set", "layout": {"dev1": {"s": "f1", "x": 0.5, "y": 0.5}}}
)
assert (await client.receive_json())["success"]
await client.send_json_auto_id(
{"type": "houseplan/layout/update", "device_id": "dev2", "pos": {"s": "f1", "x": 0.1, "y": 0.2}}
)
assert (await client.receive_json())["success"]
await client.send_json_auto_id({"type": "houseplan/layout/delete", "device_id": "dev1"})
assert (await client.receive_json())["success"]
await client.send_json_auto_id({"type": "houseplan/layout/get"})
resp = await client.receive_json()
assert set(resp["result"]["layout"]) == {"dev2"}
async def test_config_rev_conflict(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None:
await _setup(hass)
client = await hass_ws_client(hass)
cfg = {"spaces": [], "markers": [], "settings": {}}
await client.send_json_auto_id({"type": "houseplan/config/set", "config": cfg, "expected_rev": 0})
resp = await client.receive_json()
assert resp["success"] and resp["result"]["rev"] == 1
# stale expected_rev must be rejected with `conflict`
await client.send_json_auto_id({"type": "houseplan/config/set", "config": cfg, "expected_rev": 0})
resp = await client.receive_json()
assert not resp["success"] and resp["error"]["code"] == "conflict"
await client.send_json_auto_id({"type": "houseplan/config/get"})
resp = await client.receive_json()
assert resp["result"]["rev"] == 1
async def test_not_ready_without_entry(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None:
"""WS commands answer not_ready when the integration has no loaded entry."""
# register commands without setting up an entry
from custom_components.houseplan import async_setup
assert await async_setup(hass, {})
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "houseplan/layout/get"})
resp = await client.receive_json()
assert not resp["success"] and resp["error"]["code"] == "not_ready"
async def test_plan_set_validates(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None:
await _setup(hass)
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{"type": "houseplan/plan/set", "space_id": "../evil", "ext": "png", "data": "aGk="}
)
resp = await client.receive_json()
assert not resp["success"] and resp["error"]["code"] == "invalid_space_id"
await client.send_json_auto_id(
{"type": "houseplan/plan/set", "space_id": "s1", "ext": "png", "data": "%%%not-base64%%%"}
)
resp = await client.receive_json()
assert not resp["success"] and resp["error"]["code"] == "invalid_data"
await client.send_json_auto_id(
{"type": "houseplan/plan/set", "space_id": "s1", "ext": "png", "data": "aGVsbG8="}
)
resp = await client.receive_json()
assert resp["success"] and resp["result"]["url"].startswith("/houseplan_files/plans/s1.png?v=")