mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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,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))
|
lambda: (plans_path.mkdir(parents=True, exist_ok=True), files_path.mkdir(parents=True, exist_ok=True))
|
||||||
)
|
)
|
||||||
|
|
||||||
static_paths = []
|
# Static paths cannot be unregistered — register once per HA run.
|
||||||
try:
|
if not hass.data[DOMAIN].get("static_registered"):
|
||||||
from homeassistant.components.http import StaticPathConfig
|
hass.data[DOMAIN]["static_registered"] = True
|
||||||
|
static_paths = []
|
||||||
|
try:
|
||||||
|
from homeassistant.components.http import StaticPathConfig
|
||||||
|
|
||||||
if card_path.exists():
|
if card_path.exists():
|
||||||
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
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(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)
|
||||||
hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True)
|
hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True)
|
||||||
|
|
||||||
if not card_path.exists():
|
if not card_path.exists():
|
||||||
_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(
|
||||||
|
|||||||
@@ -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","");
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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 и загрузите план заново."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 load→modify→save 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})
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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","");
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -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=")
|
||||||
Reference in New Issue
Block a user