From 42c24abcb430d39b33ea7037426f97f2d8ec133f Mon Sep 17 00:00:00 2001 From: Matysh Date: Mon, 6 Jul 2026 00:27:47 +0300 Subject: [PATCH] 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 --- .github/workflows/validate.yml | 6 +- custom_components/houseplan/__init__.py | 158 ++++++++++++------ custom_components/houseplan/config_flow.py | 2 - custom_components/houseplan/const.py | 3 +- custom_components/houseplan/diagnostics.py | 41 +++++ .../houseplan/frontend/houseplan-card.js | 2 +- custom_components/houseplan/http_api.py | 5 +- custom_components/houseplan/manifest.json | 5 +- .../houseplan/quality_scale.yaml | 112 +++++++++++++ custom_components/houseplan/store.py | 69 ++++++++ custom_components/houseplan/strings.json | 24 +++ custom_components/houseplan/system_health.py | 32 ++++ .../houseplan/translations/en.json | 18 +- .../houseplan/translations/ru.json | 18 +- custom_components/houseplan/websocket_api.py | 73 ++++---- dist/houseplan-card.js | 2 +- docs/CHANGELOG.md | 24 +++ docs/STATUS.md | 7 +- package.json | 2 +- src/houseplan-card.ts | 2 +- tests_backend/conftest.py | 19 +++ tests_backend/test_ha_config_flow.py | 50 ++++++ tests_backend/test_ha_setup.py | 28 ++++ tests_backend/test_ha_upload.py | 49 ++++++ tests_backend/test_ha_websocket.py | 94 +++++++++++ 25 files changed, 745 insertions(+), 100 deletions(-) create mode 100644 custom_components/houseplan/diagnostics.py create mode 100644 custom_components/houseplan/quality_scale.yaml create mode 100644 custom_components/houseplan/store.py create mode 100644 custom_components/houseplan/strings.json create mode 100644 custom_components/houseplan/system_health.py create mode 100644 tests_backend/conftest.py create mode 100644 tests_backend/test_ha_config_flow.py create mode 100644 tests_backend/test_ha_setup.py create mode 100644 tests_backend/test_ha_upload.py create mode 100644 tests_backend/test_ha_websocket.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index a7c48b2..6eda51a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -39,7 +39,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 - with: { python-version: "3.12" } - - run: pip install pytest voluptuous - - name: Backend unit tests + with: { python-version: "3.13" } + - run: pip install pytest voluptuous pytest-homeassistant-custom-component + - name: Backend unit tests (pure + HA harness) run: python -m pytest tests_backend/ -q diff --git a/custom_components/houseplan/__init__.py b/custom_components/houseplan/__init__.py index b8a06db..8137a93 100755 --- a/custom_components/houseplan/__init__.py +++ b/custom_components/houseplan/__init__.py @@ -1,13 +1,13 @@ -"""House Plan: server-side house plan configuration + serving the Lovelace card.""" +"""House Plan: server-side house plan configuration + Lovelace card serving.""" from __future__ import annotations import logging from pathlib import Path from homeassistant.components.frontend import add_extra_js_url -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.storage import Store +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import issue_registry as ir from . import websocket_api as hp_ws from .const import ( @@ -17,20 +17,16 @@ from .const import ( FRONTEND_URL, PLANS_DIR, PLANS_URL, - STORAGE_CONFIG_KEY, - STORAGE_KEY, - STORAGE_VERSION, VERSION, ) +from .store import HouseplanConfigEntry, create_data _LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config) -> bool: - """Register WS commands, the HTTP upload and the stores on startup.""" + """Register global handlers (survive config-entry reloads): WS commands, HTTP view.""" hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN]["store"] = Store(hass, STORAGE_VERSION, STORAGE_KEY) - hass.data[DOMAIN]["config_store"] = Store(hass, STORAGE_VERSION, STORAGE_CONFIG_KEY) hp_ws.async_register(hass) from .http_api import HouseplanUploadView @@ -38,11 +34,16 @@ async def async_setup(hass: HomeAssistant, config) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Config entry: static frontend and plan files + auto-registration of the JS.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN]["entry"] = entry - entry.async_on_unload(entry.add_update_listener(_update_listener)) +async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool: + """Config entry: stores in runtime_data, static paths, card auto-registration.""" + data = create_data(hass) + # test-before-setup: storage must be readable, otherwise retry later + try: + await data.store.async_load() + await data.config_store.async_load() + except Exception as err: # noqa: BLE001 — corrupt/unreadable .storage + raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err + entry.runtime_data = data card_path = Path(__file__).parent / "frontend" / "houseplan-card.js" plans_path = Path(hass.config.path(PLANS_DIR)) @@ -51,46 +52,118 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: lambda: (plans_path.mkdir(parents=True, exist_ok=True), files_path.mkdir(parents=True, exist_ok=True)) ) - static_paths = [] - try: - from homeassistant.components.http import StaticPathConfig + # Static paths cannot be unregistered — register once per HA run. + if not hass.data[DOMAIN].get("static_registered"): + hass.data[DOMAIN]["static_registered"] = True + static_paths = [] + try: + from homeassistant.components.http import StaticPathConfig - if card_path.exists(): - static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False)) - static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True)) - static_paths.append(StaticPathConfig(FILES_URL, str(files_path), cache_headers=True)) - await hass.http.async_register_static_paths(static_paths) - except ImportError: # older HA versions - if card_path.exists(): - hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False) - hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True) - hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True) + if card_path.exists(): + static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False)) + static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True)) + static_paths.append(StaticPathConfig(FILES_URL, str(files_path), cache_headers=True)) + await hass.http.async_register_static_paths(static_paths) + except ImportError: # very old HA versions + if card_path.exists(): + hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False) + hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True) + hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True) if not card_path.exists(): _LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path) return True - # Register the card. Preferably as a Lovelace resource (the frontend WAITS for those - # before rendering dashboards, so the card is available even on a cold start of the mobile - # app). If the resource registry is unavailable (Lovelace YAML mode, older versions) — - # fall back to extra_module_url. + # Register the card. Preferably as a Lovelace resource (the frontend AWAITS + # resources before rendering dashboards, so the card is available even on a cold + # start of the mobile app). If the resource registry is unavailable (YAML-mode + # Lovelace, old versions) — fall back to extra_module_url. module_url = f"{FRONTEND_URL}?v={VERSION}" if not await _register_lovelace_resource(hass, module_url): add_extra_js_url(hass, module_url) + + await _check_plan_files(hass, entry) return True +async def _check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntry) -> None: + """Repairs: raise an issue for every space whose plan file is missing on disk.""" + cfg_raw = await entry.runtime_data.config_store.async_load() or {} + spaces = cfg_raw.get("config", {}).get("spaces", []) + plans_dir = Path(hass.config.path(PLANS_DIR)) + + def _missing() -> list[tuple[str, str]]: + res = [] + for sp in spaces: + url = sp.get("plan_url") or "" + if not url.startswith(PLANS_URL + "/"): + continue # external/legacy URL — not ours to verify + fname = url[len(PLANS_URL) + 1 :].split("?", 1)[0] + if not (plans_dir / fname).is_file(): + res.append((sp.get("id", "?"), fname)) + return res + + missing = await hass.async_add_executor_job(_missing) + seen = set() + for space_id, fname in missing: + seen.add(space_id) + ir.async_create_issue( + hass, + DOMAIN, + f"broken_plan_{space_id}", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="broken_plan", + translation_placeholders={"space": space_id, "file": fname}, + ) + # clear stale issues for spaces that are fine again (or gone) + for sp in spaces: + sid = sp.get("id", "?") + if sid not in seen: + ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}") + + +async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool: + """Unload the entry. + + WS commands and the HTTP view are global (async_setup) and stay registered — + their handlers resolve runtime data per call and answer `not_ready` while no + entry is loaded. Static paths cannot be unregistered by design. + """ + return True + + +async def async_remove_entry(hass: HomeAssistant, entry) -> None: + """Clean up on integration removal: drop our Lovelace resource entry.""" + try: + resources = _lovelace_resources(hass) + if resources is None or not hasattr(resources, "async_delete_item"): + return + for item in list(resources.async_items()): + if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL: + await resources.async_delete_item(item["id"]) + _LOGGER.debug("House Plan Lovelace resource removed: %s", item.get("url")) + except Exception as err: # noqa: BLE001 — best-effort cleanup + _LOGGER.debug("Could not remove the Lovelace resource on uninstall: %s", err) + + +def _lovelace_resources(hass: HomeAssistant): + lovelace = hass.data.get("lovelace") + resources = getattr(lovelace, "resources", None) + if resources is None and isinstance(lovelace, dict): + resources = lovelace.get("resources") + return resources + + async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool: """Register (or update) the card in the Lovelace resource registry. - Returns True on success. Writes idempotently: if a resource with our path already exists — - update the URL on a version change; if absent — create it. Any-except → False (fallback to JS). + Returns True on success. Idempotent: if a resource with our path exists — + update the URL on version change; otherwise create it. Any exception → False + (fall back to extra_module_url). """ try: - lovelace = hass.data.get("lovelace") - resources = getattr(lovelace, "resources", None) - if resources is None and isinstance(lovelace, dict): - resources = lovelace.get("resources") + resources = _lovelace_resources(hass) if resources is None: return False # the resource registry must be loaded @@ -116,14 +189,5 @@ async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> b _LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url) return True except Exception as err: # noqa: BLE001 — any failure → fallback - _LOGGER.debug("Failed to register the Lovelace resource (%s), falling back to extra_module_url", err) + _LOGGER.debug("Could not register the Lovelace resource (%s), falling back to extra_module_url", err) return False - - -async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - hass.data[DOMAIN]["entry"] = entry - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - hass.data[DOMAIN].pop("entry", None) - return True diff --git a/custom_components/houseplan/config_flow.py b/custom_components/houseplan/config_flow.py index 4696212..59aa301 100755 --- a/custom_components/houseplan/config_flow.py +++ b/custom_components/houseplan/config_flow.py @@ -14,8 +14,6 @@ class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 async def async_step_user(self, user_input=None): - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") if user_input is not None: return self.async_create_entry(title="House Plan", data={}, options=user_input) return self.async_show_form( diff --git a/custom_components/houseplan/const.py b/custom_components/houseplan/const.py index fadb0f0..feb761a 100755 --- a/custom_components/houseplan/const.py +++ b/custom_components/houseplan/const.py @@ -4,13 +4,14 @@ DOMAIN = "houseplan" STORAGE_KEY = f"{DOMAIN}.layout" STORAGE_CONFIG_KEY = f"{DOMAIN}.config" STORAGE_VERSION = 1 +STORAGE_MINOR_VERSION = 1 FRONTEND_URL = "/houseplan_files/houseplan-card.js" PLANS_URL = "/houseplan_files/plans" PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory FILES_URL = "/houseplan_files/files" FILES_DIR = "houseplan/files" CONF_ADMIN_ONLY = "admin_only" -VERSION = "1.11.2" +VERSION = "1.12.0" DEFAULT_CONFIG: dict = { "spaces": [], diff --git a/custom_components/houseplan/diagnostics.py b/custom_components/houseplan/diagnostics.py new file mode 100644 index 0000000..135cc7c --- /dev/null +++ b/custom_components/houseplan/diagnostics.py @@ -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), + } diff --git a/custom_components/houseplan/frontend/houseplan-card.js b/custom_components/houseplan/frontend/houseplan-card.js index 9d5fa19..68f5f3e 100755 --- a/custom_components/houseplan/frontend/houseplan-card.js +++ b/custom_components/houseplan/frontend/houseplan-card.js @@ -1042,4 +1042,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow - `}}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",""); + `}}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",""); diff --git a/custom_components/houseplan/http_api.py b/custom_components/houseplan/http_api.py index 5414a07..092bd5b 100644 --- a/custom_components/houseplan/http_api.py +++ b/custom_components/houseplan/http_api.py @@ -18,7 +18,8 @@ except ImportError: # older HA versions KEY_HASS = "hass" # type: ignore[assignment] from homeassistant.core import HomeAssistant -from .const import CONF_ADMIN_ONLY, DOMAIN, FILES_DIR, FILES_URL +from .const import CONF_ADMIN_ONLY, FILES_DIR, FILES_URL +from .store import get_entry from .validation import ( FILE_EXTENSIONS, MAX_FILE_BYTES, @@ -41,7 +42,7 @@ class HouseplanUploadView(HomeAssistantView): async def post(self, request: web.Request) -> web.Response: hass: HomeAssistant = request.app[KEY_HASS] - entry = hass.data.get(DOMAIN, {}).get("entry") + entry = get_entry(hass) admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False)) if admin_only: user = request.get("hass_user") diff --git a/custom_components/houseplan/manifest.json b/custom_components/houseplan/manifest.json index 9f394e3..d0d634a 100755 --- a/custom_components/houseplan/manifest.json +++ b/custom_components/houseplan/manifest.json @@ -15,5 +15,6 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "requirements": [], - "version": "1.11.2" -} \ No newline at end of file + "single_config_entry": true, + "version": "1.12.0" +} diff --git a/custom_components/houseplan/quality_scale.yaml b/custom_components/houseplan/quality_scale.yaml new file mode 100644 index 0000000..7175fcf --- /dev/null +++ b/custom_components/houseplan/quality_scale.yaml @@ -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. diff --git a/custom_components/houseplan/store.py b/custom_components/houseplan/store.py new file mode 100644 index 0000000..66d8762 --- /dev/null +++ b/custom_components/houseplan/store.py @@ -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 diff --git a/custom_components/houseplan/strings.json b/custom_components/houseplan/strings.json new file mode 100644 index 0000000..d95c59b --- /dev/null +++ b/custom_components/houseplan/strings.json @@ -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." + } + } +} diff --git a/custom_components/houseplan/system_health.py b/custom_components/houseplan/system_health.py new file mode 100644 index 0000000..47a8f40 --- /dev/null +++ b/custom_components/houseplan/system_health.py @@ -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", {})), + } diff --git a/custom_components/houseplan/translations/en.json b/custom_components/houseplan/translations/en.json index 8e56e6d..bd9caee 100755 --- a/custom_components/houseplan/translations/en.json +++ b/custom_components/houseplan/translations/en.json @@ -3,16 +3,28 @@ "step": { "user": { "title": "House Plan", - "data": { "admin_only": "Only administrators may edit the layout" } + "data": { + "admin_only": "Only administrators may edit the layout" + } } }, - "abort": { "single_instance_allowed": "Already configured — only one entry is allowed." } + "abort": { + "single_instance_allowed": "Already configured — only one entry is allowed." + } }, "options": { "step": { "init": { - "data": { "admin_only": "Only administrators may edit the layout" } + "data": { + "admin_only": "Only administrators may edit the layout" + } } } + }, + "issues": { + "broken_plan": { + "title": "Floor plan image is missing", + "description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again." + } } } diff --git a/custom_components/houseplan/translations/ru.json b/custom_components/houseplan/translations/ru.json index 1f48c02..c479256 100755 --- a/custom_components/houseplan/translations/ru.json +++ b/custom_components/houseplan/translations/ru.json @@ -3,16 +3,28 @@ "step": { "user": { "title": "House Plan", - "data": { "admin_only": "Правка раскладки только администраторами" } + "data": { + "admin_only": "Правка раскладки только администраторами" + } } }, - "abort": { "single_instance_allowed": "Уже настроено — допускается одна запись." } + "abort": { + "single_instance_allowed": "Уже настроено — допускается одна запись." + } }, "options": { "step": { "init": { - "data": { "admin_only": "Правка раскладки только администраторами" } + "data": { + "admin_only": "Правка раскладки только администраторами" + } } } + }, + "issues": { + "broken_plan": { + "title": "Файл плана этажа не найден", + "description": "Файл плана `{file}` пространства `{space}` не найден в `config/houseplan/plans/`. Откройте настройки пространства в карточке House Plan и загрузите план заново." + } } } diff --git a/custom_components/houseplan/websocket_api.py b/custom_components/houseplan/websocket_api.py index c04a2a2..3c6c5b2 100755 --- a/custom_components/houseplan/websocket_api.py +++ b/custom_components/houseplan/websocket_api.py @@ -1,7 +1,6 @@ """House Plan WS commands: layout, space configuration, plan uploads.""" from __future__ import annotations -import asyncio import base64 import binascii from pathlib import Path @@ -13,9 +12,10 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from .const import ( - CONF_ADMIN_ONLY, DEFAULT_CONFIG, DOMAIN, + CONF_ADMIN_ONLY, DEFAULT_CONFIG, PLANS_DIR, PLANS_URL, ) +from .store import HouseplanData, get_data, get_entry from .validation import ( CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES, PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id, @@ -34,25 +34,21 @@ def async_register(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_plan_set) -def _store(hass: HomeAssistant): - return hass.data[DOMAIN]["store"] +def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None: + """Runtime data of the loaded entry; answers `not_ready` when not set up. - -def _config_store(hass: HomeAssistant): - return hass.data[DOMAIN]["config_store"] - - -def _write_lock(hass: HomeAssistant) -> asyncio.Lock: - """A single lock over the load→modify→save cycle of both stores. - - Without it, parallel WS calls lose changes (last-writer-wins), + The write_lock inside serializes every load→modify→save cycle of both + stores: without it parallel WS calls lose changes (last-writer-wins) and the expected_rev check is not atomic. """ - return hass.data[DOMAIN].setdefault("write_lock", asyncio.Lock()) + data = get_data(hass) + if data is None: + connection.send_error(msg_id, "not_ready", "House Plan is not set up") + return data def _check_write(hass: HomeAssistant, connection) -> bool: - entry = hass.data[DOMAIN].get("entry") + entry = get_entry(hass) admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False)) return connection.user.is_admin if admin_only else True @@ -64,7 +60,10 @@ def _check_write(hass: HomeAssistant, connection) -> bool: @websocket_api.async_response async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: """Return the saved layout.""" - data = await _store(hass).async_load() or {} + rt = _runtime(hass, connection, msg["id"]) + if rt is None: + return + data = await rt.store.async_load() or {} connection.send_result(msg["id"], {"layout": data.get("layout", {})}) @@ -77,8 +76,11 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> if not _check_write(hass, connection): connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout") return - async with _write_lock(hass): - await _store(hass).async_save({"layout": msg["layout"]}) + rt = _runtime(hass, connection, msg["id"]) + if rt is None: + return + async with rt.write_lock: + await rt.store.async_save({"layout": msg["layout"]}) connection.send_result(msg["id"], {"ok": True}) @@ -95,12 +97,14 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any]) if not _check_write(hass, connection): connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout") return - store = _store(hass) - async with _write_lock(hass): - data = await store.async_load() or {} + rt = _runtime(hass, connection, msg["id"]) + if rt is None: + return + async with rt.write_lock: + data = await rt.store.async_load() or {} layout = data.get("layout", {}) layout[msg["device_id"]] = msg["pos"] - await store.async_save({"layout": layout}) + await rt.store.async_save({"layout": layout}) connection.send_result(msg["id"], {"ok": True}) @@ -116,13 +120,15 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) if not _check_write(hass, connection): connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout") return - store = _store(hass) - async with _write_lock(hass): - data = await store.async_load() or {} + rt = _runtime(hass, connection, msg["id"]) + if rt is None: + return + async with rt.write_lock: + data = await rt.store.async_load() or {} layout = data.get("layout", {}) if msg["device_id"] in layout: del layout[msg["device_id"]] - await store.async_save({"layout": layout}) + await rt.store.async_save({"layout": layout}) connection.send_result(msg["id"], {"ok": True}) @@ -133,7 +139,10 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) @websocket_api.async_response async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: """Return the configuration and its revision.""" - data = await _config_store(hass).async_load() or {} + rt = _runtime(hass, connection, msg["id"]) + if rt is None: + return + data = await rt.config_store.async_load() or {} config = {**DEFAULT_CONFIG, **data.get("config", {})} connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)}) @@ -156,9 +165,11 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> if not _check_write(hass, connection): connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the configuration") return - store = _config_store(hass) - async with _write_lock(hass): - data = await store.async_load() or {} + rt = _runtime(hass, connection, msg["id"]) + if rt is None: + return + async with rt.write_lock: + data = await rt.config_store.async_load() or {} current_rev = data.get("rev", 0) if "expected_rev" in msg and msg["expected_rev"] != current_rev: connection.send_error( @@ -167,7 +178,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> ) return new_rev = current_rev + 1 - await store.async_save({"config": msg["config"], "rev": new_rev}) + await rt.config_store.async_save({"config": msg["config"], "rev": new_rev}) hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev}) connection.send_result(msg["id"], {"ok": True, "rev": new_rev}) diff --git a/dist/houseplan-card.js b/dist/houseplan-card.js index 9d5fa19..68f5f3e 100755 --- a/dist/houseplan-card.js +++ b/dist/houseplan-card.js @@ -1042,4 +1042,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow - `}}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",""); + `}}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",""); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 18923c7..9c7c33b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,29 @@ # 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) - 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`; diff --git a/docs/STATUS.md b/docs/STATUS.md index cb9dd1e..bd4ea2d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -13,14 +13,14 @@ | 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 | | 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) | | 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 | | 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) @@ -32,6 +32,9 @@ - **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.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 diff --git a/package.json b/package.json index 1855aef..350659f 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "houseplan-card", - "version": "1.11.2", + "version": "1.12.0", "description": "Interactive house plan Lovelace card for Home Assistant", "license": "MIT", "type": "module", diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index c47291e..ace99b4 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -20,7 +20,7 @@ import './editor'; import { cardStyles } from './styles'; 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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering const LS_ZOOM = 'houseplan_card_zoom_v1'; diff --git a/tests_backend/conftest.py b/tests_backend/conftest.py new file mode 100644 index 0000000..5621b52 --- /dev/null +++ b/tests_backend/conftest.py @@ -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 diff --git a/tests_backend/test_ha_config_flow.py b/tests_backend/test_ha_config_flow.py new file mode 100644 index 0000000..3683685 --- /dev/null +++ b/tests_backend/test_ha_config_flow.py @@ -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} diff --git a/tests_backend/test_ha_setup.py b/tests_backend/test_ha_setup.py new file mode 100644 index 0000000..60fcf86 --- /dev/null +++ b/tests_backend/test_ha_setup.py @@ -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" diff --git a/tests_backend/test_ha_upload.py b/tests_backend/test_ha_upload.py new file mode 100644 index 0000000..2a79fc8 --- /dev/null +++ b/tests_backend/test_ha_upload.py @@ -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"] diff --git a/tests_backend/test_ha_websocket.py b/tests_backend/test_ha_websocket.py new file mode 100644 index 0000000..2a695a2 --- /dev/null +++ b/tests_backend/test_ha_websocket.py @@ -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=")