mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a12f731df8 | ||
|
|
afa8398602 | ||
|
|
8b22fbed05 | ||
|
|
a4b3a4a83f | ||
|
|
290b107c49 | ||
|
|
4f6bd68e5b | ||
|
|
42c24abcb4 | ||
|
|
8d9bf896e2 | ||
|
|
8824f3b06d |
@@ -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 home-assistant-frontend
|
||||
- name: Backend unit tests (pure + HA harness)
|
||||
run: python -m pytest tests_backend/ -q
|
||||
|
||||
@@ -84,6 +84,8 @@ Nothing else needs to be specified — everything else is configured right on th
|
||||
### Step 1. Add a space (floor)
|
||||
|
||||
On first open the plan is still empty — House Plan immediately offers to create the first space.
|
||||
If your Home Assistant already has **floors** configured, a wizard offers to create a space
|
||||
for each floor (names prefilled, a plan image is asked for one by one; any floor can be skipped).
|
||||
|
||||

|
||||
|
||||
@@ -125,6 +127,22 @@ Device icons can be **dragged with the mouse at any time** — no separate "edit
|
||||
|
||||

|
||||
|
||||
### Tap actions: control devices from the plan
|
||||
|
||||
By default a tap on an icon opens its info card. In the card settings you can switch
|
||||
**Tap on a device** to *Toggle* — a tap then switches lights, sockets, fans and
|
||||
humidifiers directly on the plan (wall-tablet style). For safety, a card-wide toggle
|
||||
never affects locks, alarms, covers or valves; you can consciously enable toggle for a
|
||||
specific device (except locks and alarms — those never toggle from the plan) in its
|
||||
edit dialog. A **long press** always opens the info card.
|
||||
|
||||
### Icon rules
|
||||
|
||||
Which MDI icon a device gets is decided by **icon rules** — editable right in the card
|
||||
(the ⬡ button in the header): an ordered list of “name pattern → icon” regexes with a
|
||||
live test field, bilingual defaults (EN/RU) and a one-click reset. When no rule
|
||||
matches, the entity *device class* decides (thermometer for temperature sensors, etc.).
|
||||
|
||||
### Step 6. Adding your own devices manually
|
||||
|
||||
Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
|
||||
|
||||
@@ -85,6 +85,10 @@ title: План дома
|
||||
|
||||
При первом открытии план ещё пуст — House Plan сразу предложит создать первое пространство.
|
||||
|
||||
Если в вашем Home Assistant уже настроены **этажи**, мастер предложит создать
|
||||
пространство для каждого: названия подставятся сами, план попросит по очереди,
|
||||
любой этаж можно пропустить.
|
||||
|
||||

|
||||
|
||||
В диалоге задайте **название** (например, «1 этаж») и **загрузите подложку** — картинку плана этажа в формате SVG, PNG или JPG. Оба поля обязательны: без плана кнопка «Сохранить» неактивна.
|
||||
@@ -125,6 +129,24 @@ title: План дома
|
||||
|
||||

|
||||
|
||||
### Управление с плана (tap actions)
|
||||
|
||||
По умолчанию тап по значку открывает инфо-карточку. В настройках карточки можно
|
||||
переключить **«Тап по устройству»** на *Переключить* — тогда тап включает/выключает
|
||||
свет, розетки, вентиляторы и увлажнители прямо с плана (режим настенного планшета).
|
||||
Для безопасности общий toggle не действует на замки, сигнализации, шторы/ворота и
|
||||
клапаны; для конкретного устройства toggle можно включить осознанно в его диалоге
|
||||
(кроме замков и сигнализаций — они с плана не переключаются никогда). **Долгое
|
||||
нажатие** всегда открывает инфо-карточку.
|
||||
|
||||
### Правила иконок
|
||||
|
||||
Какая MDI-иконка достанется устройству, решают **правила иконок** — редактируются
|
||||
прямо в карточке (кнопка ⬡ в шапке): упорядоченный список «шаблон имени → иконка»
|
||||
с живым тест-полем, двуязычные умолчания (EN/RU) и сброс одной кнопкой. Если ни одно
|
||||
правило не подошло — решает *device class* сущности (термометр для датчиков
|
||||
температуры и т.п.).
|
||||
|
||||
### Шаг 6. Добавление своих устройств вручную
|
||||
|
||||
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""House Plan: server-side house plan configuration + serving the Lovelace card."""
|
||||
"""House Plan: server-side house plan configuration + Lovelace card serving."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from . import websocket_api as hp_ws
|
||||
from .const import (
|
||||
@@ -17,20 +17,16 @@ from .const import (
|
||||
FRONTEND_URL,
|
||||
PLANS_DIR,
|
||||
PLANS_URL,
|
||||
STORAGE_CONFIG_KEY,
|
||||
STORAGE_KEY,
|
||||
STORAGE_VERSION,
|
||||
VERSION,
|
||||
)
|
||||
from .store import HouseplanConfigEntry, create_data
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
"""Register WS commands, the HTTP upload and the stores on startup."""
|
||||
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN]["store"] = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
hass.data[DOMAIN]["config_store"] = Store(hass, STORAGE_VERSION, STORAGE_CONFIG_KEY)
|
||||
hp_ws.async_register(hass)
|
||||
from .http_api import HouseplanUploadView
|
||||
|
||||
@@ -38,11 +34,16 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Config entry: static frontend and plan files + auto-registration of the JS."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN]["entry"] = entry
|
||||
entry.async_on_unload(entry.add_update_listener(_update_listener))
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
||||
"""Config entry: stores in runtime_data, static paths, card auto-registration."""
|
||||
data = create_data(hass)
|
||||
# test-before-setup: storage must be readable, otherwise retry later
|
||||
try:
|
||||
await data.store.async_load()
|
||||
await data.config_store.async_load()
|
||||
except Exception as err: # noqa: BLE001 — corrupt/unreadable .storage
|
||||
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
|
||||
entry.runtime_data = data
|
||||
|
||||
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
|
||||
plans_path = Path(hass.config.path(PLANS_DIR))
|
||||
@@ -51,46 +52,118 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
lambda: (plans_path.mkdir(parents=True, exist_ok=True), files_path.mkdir(parents=True, exist_ok=True))
|
||||
)
|
||||
|
||||
static_paths = []
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
# Static paths cannot be unregistered — register once per HA run.
|
||||
if not hass.data[DOMAIN].get("static_registered"):
|
||||
hass.data[DOMAIN]["static_registered"] = True
|
||||
static_paths = []
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
if card_path.exists():
|
||||
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
||||
static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True))
|
||||
static_paths.append(StaticPathConfig(FILES_URL, str(files_path), cache_headers=True))
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
except ImportError: # older HA versions
|
||||
if card_path.exists():
|
||||
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
||||
hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True)
|
||||
hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True)
|
||||
if card_path.exists():
|
||||
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
||||
static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True))
|
||||
static_paths.append(StaticPathConfig(FILES_URL, str(files_path), cache_headers=True))
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
except ImportError: # very old HA versions
|
||||
if card_path.exists():
|
||||
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
||||
hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True)
|
||||
hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True)
|
||||
|
||||
if not card_path.exists():
|
||||
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
|
||||
return True
|
||||
|
||||
# Register the card. Preferably as a Lovelace resource (the frontend WAITS for those
|
||||
# before rendering dashboards, so the card is available even on a cold start of the mobile
|
||||
# app). If the resource registry is unavailable (Lovelace YAML mode, older versions) —
|
||||
# fall back to extra_module_url.
|
||||
# Register the card. Preferably as a Lovelace resource (the frontend AWAITS
|
||||
# resources before rendering dashboards, so the card is available even on a cold
|
||||
# start of the mobile app). If the resource registry is unavailable (YAML-mode
|
||||
# Lovelace, old versions) — fall back to extra_module_url.
|
||||
module_url = f"{FRONTEND_URL}?v={VERSION}"
|
||||
if not await _register_lovelace_resource(hass, module_url):
|
||||
add_extra_js_url(hass, module_url)
|
||||
|
||||
await _check_plan_files(hass, entry)
|
||||
return True
|
||||
|
||||
|
||||
async def _check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntry) -> None:
|
||||
"""Repairs: raise an issue for every space whose plan file is missing on disk."""
|
||||
cfg_raw = await entry.runtime_data.config_store.async_load() or {}
|
||||
spaces = cfg_raw.get("config", {}).get("spaces", [])
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
|
||||
def _missing() -> list[tuple[str, str]]:
|
||||
res = []
|
||||
for sp in spaces:
|
||||
url = sp.get("plan_url") or ""
|
||||
if not url.startswith(PLANS_URL + "/"):
|
||||
continue # external/legacy URL — not ours to verify
|
||||
fname = url[len(PLANS_URL) + 1 :].split("?", 1)[0]
|
||||
if not (plans_dir / fname).is_file():
|
||||
res.append((sp.get("id", "?"), fname))
|
||||
return res
|
||||
|
||||
missing = await hass.async_add_executor_job(_missing)
|
||||
seen = set()
|
||||
for space_id, fname in missing:
|
||||
seen.add(space_id)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"broken_plan_{space_id}",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="broken_plan",
|
||||
translation_placeholders={"space": space_id, "file": fname},
|
||||
)
|
||||
# clear stale issues for spaces that are fine again (or gone)
|
||||
for sp in spaces:
|
||||
sid = sp.get("id", "?")
|
||||
if sid not in seen:
|
||||
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
||||
"""Unload the entry.
|
||||
|
||||
WS commands and the HTTP view are global (async_setup) and stay registered —
|
||||
their handlers resolve runtime data per call and answer `not_ready` while no
|
||||
entry is loaded. Static paths cannot be unregistered by design.
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry) -> None:
|
||||
"""Clean up on integration removal: drop our Lovelace resource entry."""
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None or not hasattr(resources, "async_delete_item"):
|
||||
return
|
||||
for item in list(resources.async_items()):
|
||||
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL:
|
||||
await resources.async_delete_item(item["id"])
|
||||
_LOGGER.debug("House Plan Lovelace resource removed: %s", item.get("url"))
|
||||
except Exception as err: # noqa: BLE001 — best-effort cleanup
|
||||
_LOGGER.debug("Could not remove the Lovelace resource on uninstall: %s", err)
|
||||
|
||||
|
||||
def _lovelace_resources(hass: HomeAssistant):
|
||||
lovelace = hass.data.get("lovelace")
|
||||
resources = getattr(lovelace, "resources", None)
|
||||
if resources is None and isinstance(lovelace, dict):
|
||||
resources = lovelace.get("resources")
|
||||
return resources
|
||||
|
||||
|
||||
async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool:
|
||||
"""Register (or update) the card in the Lovelace resource registry.
|
||||
|
||||
Returns True on success. Writes idempotently: if a resource with our path already exists —
|
||||
update the URL on a version change; if absent — create it. Any-except → False (fallback to JS).
|
||||
Returns True on success. Idempotent: if a resource with our path exists —
|
||||
update the URL on version change; otherwise create it. Any exception → False
|
||||
(fall back to extra_module_url).
|
||||
"""
|
||||
try:
|
||||
lovelace = hass.data.get("lovelace")
|
||||
resources = getattr(lovelace, "resources", None)
|
||||
if resources is None and isinstance(lovelace, dict):
|
||||
resources = lovelace.get("resources")
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None:
|
||||
return False
|
||||
# the resource registry must be loaded
|
||||
@@ -116,14 +189,5 @@ async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> b
|
||||
_LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url)
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001 — any failure → fallback
|
||||
_LOGGER.debug("Failed to register the Lovelace resource (%s), falling back to extra_module_url", err)
|
||||
_LOGGER.debug("Could not register the Lovelace resource (%s), falling back to extra_module_url", err)
|
||||
return False
|
||||
|
||||
|
||||
async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
hass.data[DOMAIN]["entry"] = entry
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data[DOMAIN].pop("entry", None)
|
||||
return True
|
||||
|
||||
@@ -14,8 +14,6 @@ class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="House Plan", data={}, options=user_input)
|
||||
return self.async_show_form(
|
||||
|
||||
@@ -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.13.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Diagnostics for House Plan (Settings → ... → Download diagnostics)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .store import HouseplanConfigEntry
|
||||
|
||||
# Marker metadata may contain personal notes, external links and manual filenames.
|
||||
TO_REDACT = {"link", "description", "pdfs", "name"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: HouseplanConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return a redacted dump of the stores."""
|
||||
data = entry.runtime_data
|
||||
cfg_raw = await data.config_store.async_load() or {}
|
||||
layout_raw = await data.store.async_load() or {}
|
||||
config = cfg_raw.get("config", {})
|
||||
layout = layout_raw.get("layout", {})
|
||||
return {
|
||||
"options": dict(entry.options),
|
||||
"rev": cfg_raw.get("rev", 0),
|
||||
"spaces": [
|
||||
{
|
||||
"id": s.get("id"),
|
||||
"aspect": s.get("aspect"),
|
||||
"has_plan": bool(s.get("plan_url")),
|
||||
"rooms": len(s.get("rooms", [])),
|
||||
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
|
||||
"segments": len(s.get("segments", [])),
|
||||
}
|
||||
for s in config.get("spaces", [])
|
||||
],
|
||||
"markers": async_redact_data(config.get("markers", []), TO_REDACT),
|
||||
"settings": config.get("settings", {}),
|
||||
"layout_entries": len(layout),
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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")
|
||||
|
||||
@@ -15,5 +15,6 @@
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"version": "1.11.2"
|
||||
}
|
||||
"single_config_entry": true,
|
||||
"version": "1.13.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": {
|
||||
"user": {
|
||||
"title": "House Plan",
|
||||
"data": { "admin_only": "Only administrators may edit the layout" }
|
||||
"data": {
|
||||
"admin_only": "Only administrators may edit the layout"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": { "single_instance_allowed": "Already configured — only one entry is allowed." }
|
||||
"abort": {
|
||||
"single_instance_allowed": "Already configured — only one entry is allowed."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": { "admin_only": "Only administrators may edit the layout" }
|
||||
"data": {
|
||||
"admin_only": "Only administrators may edit the layout"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"broken_plan": {
|
||||
"title": "Floor plan image is missing",
|
||||
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,28 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "House Plan",
|
||||
"data": { "admin_only": "Правка раскладки только администраторами" }
|
||||
"data": {
|
||||
"admin_only": "Правка раскладки только администраторами"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": { "single_instance_allowed": "Уже настроено — допускается одна запись." }
|
||||
"abort": {
|
||||
"single_instance_allowed": "Уже настроено — допускается одна запись."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": { "admin_only": "Правка раскладки только администраторами" }
|
||||
"data": {
|
||||
"admin_only": "Правка раскладки только администраторами"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"broken_plan": {
|
||||
"title": "Файл плана этажа не найден",
|
||||
"description": "Файл плана `{file}` пространства `{space}` не найден в `config/houseplan/plans/`. Откройте настройки пространства в карточке House Plan и загрузите план заново."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("model"): vol.Any(str, None),
|
||||
vol.Optional("link"): vol.Any(str, None),
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
|
||||
vol.Optional("pdfs"): [
|
||||
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
|
||||
],
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
Vendored
+189
-56
File diff suppressed because one or more lines are too long
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## v1.13.0 — 2026-07-05 (universality: floors import, icon rules, tap actions)
|
||||
- **Floors import wizard**: on first run, if the HA registry has floors, the card
|
||||
offers to create a space per floor — names prefilled, the (mandatory) plan image is
|
||||
requested step by step, any floor can be skipped; after the last one the room-markup
|
||||
mode opens automatically. No floors / old HA → the old single-dialog onboarding.
|
||||
- **Editable icon rules**: the built-in "name pattern → MDI icon" rules are now data
|
||||
(`settings.icon_rules`) with an in-card editor (⬡ in the header): reorder, delete,
|
||||
add, live test field, invalid-regex highlighting, one-click reset to the bilingual
|
||||
(EN/RU) defaults. Fallback chain: rules → entity `device_class` → generic chip.
|
||||
Invalid user regexes are skipped safely.
|
||||
- **Tap actions**: `tap_action` card option (`info` default | `more-info` | `toggle`)
|
||||
plus a per-device override in the device dialog. Safety model: a card-wide toggle
|
||||
only affects lights/switches/fans/humidifiers; covers/valves need a conscious
|
||||
per-device toggle; locks and alarm panels never toggle from the plan. A long press
|
||||
(600 ms) always opens the info card.
|
||||
- **i18n dictionaries moved to JSON** (`src/i18n/{en,ru}.json`) — new languages can be
|
||||
contributed without touching TypeScript; tests enforce key and placeholder parity.
|
||||
- Light-theme pass: hardcoded dark badge backgrounds replaced with theme variables.
|
||||
- Tests: 28 frontend (was 15) — tap-action resolver incl. security cases, icon-rule
|
||||
compilation/overrides/device-class fallback, floors sorting, i18n parity.
|
||||
|
||||
## 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`;
|
||||
still resizable vertically.
|
||||
|
||||
## v1.11.1 — 2026-07-05 (brand images shipped inside the integration)
|
||||
- Brand icon and logo (256/512, transparent background) now live in
|
||||
`custom_components/houseplan/brand/` — the native mechanism for custom
|
||||
|
||||
+16
-4
@@ -2,10 +2,22 @@
|
||||
|
||||
## Environment (cowork sessions)
|
||||
|
||||
- The source of truth for the code is the **git repository** (lives in `/tmp/hpc` during a session,
|
||||
restored from `houseplan-card.git.bundle`: `git clone houseplan-card.git.bundle hpc`).
|
||||
- The user's folder `houseplan/houseplan-card/` is a mirror of the repo (rsync after every commit)
|
||||
+ an up-to-date `houseplan-card.git.bundle`.
|
||||
- The source of truth is **GitHub `main`** (https://github.com/Matysh/houseplan-card).
|
||||
In a sandbox session restore from it or from `houseplan-card.git.bundle`
|
||||
(`git clone houseplan-card.git.bundle hpcN` into a **fresh** /tmp directory).
|
||||
- The user's folder `houseplan/houseplan-card/` is a file mirror (synced after every commit)
|
||||
+ an up-to-date `houseplan-card.git.bundle`. The mount cannot delete files — stale
|
||||
artifacts linger there; git is authoritative.
|
||||
- `/tmp` persists between sessions, **but files created in previous sessions belong to
|
||||
`nobody` and are unreadable** (this hit `/tmp/hpc`, `/tmp/ha_jb`, `/tmp/shots/srv`).
|
||||
Always clone into a new directory and re-run `npm ci`; ask the user to re-upload `ha_jb`.
|
||||
- Headless Chromium for smoke tests: `PLAYWRIGHT_BROWSERS_PATH=/tmp/pw npx playwright
|
||||
install chromium-headless-shell`, then run with `LD_LIBRARY_PATH` pointing to the
|
||||
extracted lib dirs (`libs/lib/x86_64-linux-gnu:libs/usr/lib/x86_64-linux-gnu:.../nss`).
|
||||
- Restart HA over SSH with `nohup ha core restart >/dev/null 2>&1 </dev/null &` —
|
||||
a plain `ha core restart` holds the SSH session until the sandbox call times out.
|
||||
- GitHub pushes: classic PAT (repo+workflow scopes), created via the user's Chrome;
|
||||
stored in `~/.git-credentials` for the session.
|
||||
|
||||
### ⚠️ File-sync pitfalls (critical)
|
||||
1. The network mount sometimes serves files **truncated/scrambled** — edits via the Edit tool
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Product assessment & market position
|
||||
|
||||
*Written 2026-07-05. Sources: GitHub API star counts and HA docs verified 2026-07-06;
|
||||
see links inline. Update this file when the landscape shifts.*
|
||||
|
||||
## What the product is
|
||||
|
||||
An interactive floor plan for Home Assistant delivered as one HACS package:
|
||||
a storage **integration** (server-side config, WS API, file uploads, auth) + a
|
||||
**Lovelace card** (rendering, room markup editor, drag layout, zoom, live states,
|
||||
temperature, Zigbee LQI, device metadata with PDF manuals, virtual markers,
|
||||
en/ru localization). GUI-first: no YAML, no hand-made SVG.
|
||||
|
||||
## Competitive landscape (stars verified 2026-07-06)
|
||||
|
||||
| Competitor | Stars | Approach | Weakness we exploit |
|
||||
|---|---|---|---|
|
||||
| [ha-floorplan](https://github.com/ExperienceLovelace/ha-floorplan) (incumbent) | 1 562 | hand-made SVG + YAML/CSS/JS rules | steep barrier: Inkscape + YAML; no GUI editor; config in card YAML |
|
||||
| native `picture-elements` | built-in | absolute % coordinates in YAML | trial-and-error positioning, no zones, no editor |
|
||||
| native Areas/Home dashboard (2025.4→2025.12) | built-in | auto card grid by area/floor | **not spatial** — leaves the visual-plan niche open |
|
||||
| [Dwains Dashboard](https://github.com/dwainscheeren/dwains-lovelace-dashboard) | 2 049 | auto-generated menus | not a floor plan |
|
||||
| [Bubble Card](https://github.com/Clooos/Bubble-Card) | 4 391 | GUI card collection | not a floor plan; sets the GUI-quality bar |
|
||||
| [zigbee-floorplan-card](https://github.com/TheLarsinator/zigbee-floorplan-card) | 71 | LQI over a plan image | single-purpose; validates our LQI feature |
|
||||
| [kishorviswanathan/ha-floorplan](https://github.com/kishorviswanathan/ha-floorplan) (2026-01) | 155 | external web editor → YAML export | editor outside HA, still YAML at runtime |
|
||||
| [Padraigggs-ha-interactive-floorplan](https://github.com/Padraiggg/Padraigggs-ha-interactive-floorplan) (2026-03) | 41 | editor+viewer cards, "no YAML" | weeks old, card-only, no server-side integration |
|
||||
| [easy-floorplan](https://github.com/nicosandller/easy-floorplan) (2026-05) | 11 | draw walls/furniture in the card | drawing-centric, immature |
|
||||
|
||||
**Demand evidence:** a dedicated [Floorplan forum category](https://community.home-assistant.io/c/third-party/floorplan/28);
|
||||
the "100% Floorplan UI" mega-thread (500k+ views); a visible 2025–2026 wave of new
|
||||
floorplan projects (three launched in the last six months alone). The pain is constant:
|
||||
*people want a floor plan without Inkscape and YAML.*
|
||||
|
||||
## Honest assessment
|
||||
|
||||
**Usefulness — high.** A floor plan is the most natural "at a glance" home UI, and the
|
||||
GUI-first workflow (upload image → outline rooms → devices appear → drag) removes the
|
||||
exact barrier that keeps most users on lists of entity cards.
|
||||
|
||||
**Demand — real but niche-shaped.** Floorplan is a perennial top request, but the
|
||||
audience is enthusiasts with wall tablets. Realistic trajectory given the field:
|
||||
hundreds of stars in the first year *if* discoverability is solved (HACS default +
|
||||
demo GIF + forum post). Bubble Card (4.4k★) proves polished GUI cards can go
|
||||
quasi-mainstream; ha-floorplan's 1.5k★ with a hostile workflow shows the demand floor.
|
||||
|
||||
**Unique position — currently unoccupied.** Nobody else combines: server-side config
|
||||
(integration + `.storage`, survives dashboard edits, shared across users/devices,
|
||||
optimistic locking, live multi-client sync) + in-card room polygon editor bound to HA
|
||||
areas + curated auto-placement + drag layout + LQI/temperature overlays + config flow +
|
||||
en/ru localization + CI/quality-scale discipline. The newcomers are card-only toys so
|
||||
far; the incumbent is powerful but YAML/SVG-locked. Our moat grows if we integrate
|
||||
deeper with the areas/floors registry (Phase 9) — that is the direction HA core itself
|
||||
is signalling with the native Areas dashboard.
|
||||
|
||||
**Risks.**
|
||||
1. *HA core ships a native spatial plan.* The Areas dashboard is grid-based today, but
|
||||
core moving spatial would commoditize us. Mitigation: registry integration, speed.
|
||||
2. *Discoverability.* 0 stars until the HACS queue (~2 months) clears. Mitigation:
|
||||
demo assets + forum/Reddit showcase now, custom-repo installs meanwhile.
|
||||
3. *Single maintainer bus factor* — mitigated by docs/ discipline and tests.
|
||||
4. *Frontend API churn* (undocumented hass internals custom cards rely on).
|
||||
Mitigation: minimal surface, CI against beta HA (add to Phase 8).
|
||||
|
||||
## Recommended next moves (priority order)
|
||||
|
||||
1. **Demo GIF + English forum/Reddit posts** — cheapest adoption lever, do before the
|
||||
HACS queue clears so the storefront lands with social proof.
|
||||
2. **Phase 7 quality items** (runtime_data, unloading, storage migrations, single_config_entry)
|
||||
— cheap now, expensive later; also a credibility marker for reviewers.
|
||||
3. **Registry-driven onboarding** (import floors/areas as spaces/rooms suggestions) —
|
||||
the feature that makes first-run magical and that no competitor has.
|
||||
4. **Editable icon rules** — removes the last dacha DNA and answers the #1 predictable
|
||||
user complaint ("wrong icon for my device").
|
||||
5. **Click actions (toggle from the plan)** — most requested behavior in every
|
||||
floorplan thread; we currently only open info dialogs.
|
||||
+72
-63
@@ -1,73 +1,82 @@
|
||||
# Roadmap: from a "dacha map" to a publishable universal integration
|
||||
# Roadmap: a universal, flexible floor-plan component
|
||||
|
||||
Goal: publish to HACS (first as a custom repository, then a PR into default) a universal
|
||||
"interactive house plan" integration: custom plans per space, manual room markup,
|
||||
semi-automatic device placement, hiding/renaming/changing icons, virtual
|
||||
devices. No hardcoding of a specific house in the code.
|
||||
Goal: a **proper Home Assistant component** built to HA developer-docs patterns —
|
||||
not a hardcoded feature. Universal (any house, any language, any plan format),
|
||||
flexible (options instead of assumptions), and measured against the official
|
||||
**Integration Quality Scale** even though custom integrations are not formally graded
|
||||
(see `docs/PRODUCT.md` for the market rationale).
|
||||
|
||||
## Principles (locked in)
|
||||
- We write a proper full component following HA dev docs patterns, not a hardcoded feature.
|
||||
- All data of a specific house is **instance configuration** (server-side Store),
|
||||
not code/bundle. The current dacha data will become the first migrated instance.
|
||||
- Document everything immediately in docs/ (session context gets lost).
|
||||
- Storage versioning (Store minor_version + async_migrate) from day one.
|
||||
Phases 0–6 of the original plan (server-side config, room markup editor, device
|
||||
management, virtual devices, publication) are **done** — see CHANGELOG v1.3.0–v1.11.2.
|
||||
This roadmap replaces them.
|
||||
|
||||
## Phase 0 — Publication hygiene (quick, no new features)
|
||||
- [x] hacs.json, manifest with the required keys, custom_components/* structure
|
||||
- [x] CI: hacs/action + hassfest (workflow validate.yml) — added, verify on GitHub
|
||||
- [x] brand/icon.png
|
||||
- [ ] Public GitHub repository: description, topics, issues on; first Release v1.2.x
|
||||
- [ ] README EN (primary) + README.ru.md; screenshots/GIF (mandatory for the HACS storefront)
|
||||
- [ ] Replace codeowners/documentation/issue_tracker with real URLs after the repo is created
|
||||
## Phase 7 — Quality Scale: Bronze-complete (structural correctness)
|
||||
|
||||
## Phase 1 — Server-side config (decoupling from the dacha) ← ✅ DONE in v1.3.0 (except removing the bundle data — kept as a fallback until phase 2)
|
||||
New store `houseplan.config` (Store v1):
|
||||
```json
|
||||
{ "spaces": [ { "id": "f1", "title": "1st floor", "plan": {"media_id": "...", "type": "svg"},
|
||||
"view_box": [x,y,w,h], "rooms": [{"id","name","area_id","x","y","w","h"}] } ],
|
||||
"device_overrides": { "<device_id>": {"hidden":bool,"icon":str,"name":str} },
|
||||
"virtual_devices": [ {"id","space","name","icon","x","y","note"?, "entity_id"?} ],
|
||||
"settings": {"exclude_integrations": [...], "group_lights": bool, ...} }
|
||||
```
|
||||
- WS API v2: `houseplan/config/get|set`, `houseplan/plan/upload` (plan file →
|
||||
`<config>/houseplan/` via the process executor, served through a static path), layout as today.
|
||||
- The card: when a server config exists it uses it; the dacha bundle data becomes a
|
||||
**fallback example** and is then removed (a migration script will push it into the Store).
|
||||
- Coordinate units: normalized (0..1 of the plan) for new configs — independence from the
|
||||
source resolution; the dacha migration will convert 1489×1053 → normalized.
|
||||
Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt + comment).
|
||||
|
||||
## Phase 2 — Markup editor in the card ← ✅ CORE DONE in v1.4.0 (remaining: plan upload from the UI, view_box editing, editing existing rooms)
|
||||
- A "Setup" mode (separate from drag layout): drawing/resizing room rectangles
|
||||
on top of the plan, binding to an area (ha-area-picker selector), viewBox (frame) editing.
|
||||
- Later: polygonal rooms (SVG path), plan rotations.
|
||||
- Plan upload from the UI (file upload → WS) + picking existing media.
|
||||
- [ ] `runtime-data`: move `hass.data[DOMAIN]` (stores, entry, write lock) to typed
|
||||
`entry.runtime_data` (`type HouseplanConfigEntry = ConfigEntry[HouseplanData]`).
|
||||
Keep WS command registration in `async_setup` (commands must survive entry reloads).
|
||||
- [ ] `config-entry-unloading` (Silver, do it now): real `async_unload_entry` — unregister
|
||||
update listeners via `entry.async_on_unload`; document what stays (static paths cannot
|
||||
be unregistered — exempt comment).
|
||||
- [ ] `test-before-setup`: verify storage is loadable/writable in `async_setup_entry`,
|
||||
raise `ConfigEntryNotReady` on failure.
|
||||
- [ ] `unique-config-entry`: replace the manual `_async_current_entries()` check with
|
||||
`single_config_entry: true` in manifest.
|
||||
- [ ] `config-flow-test-coverage` + backend tests for websocket_api (pytest-homeassistant-custom-component:
|
||||
conflict/rev, admin_only enforcement, layout point ops, upload limits).
|
||||
- [ ] Storage versioning: `Store(minor_version=...)` + `_async_migrate_func` skeleton **now**,
|
||||
before the config schema evolves further (Bronze-adjacent, cheap while schema is stable).
|
||||
- [ ] `quality_scale.yaml` with honest exempt list (no entities ⇒ entity rules N/A, no polling, no deps).
|
||||
|
||||
## Phase 3 — Device management ← ✅ DONE in v1.6.x (hiding, icon, name, model, link, description, PDF, rebinding, "+", virtual)
|
||||
- A device panel in setup mode: a list of unplaced devices (with filters), drag from the panel
|
||||
onto the plan; auto-layout "grid over the room" by button.
|
||||
- Per-device overrides: hide, custom icon (ha-icon-picker), custom name. Stored in config.
|
||||
- Configurable curation: integration/domain exclusions in the options flow instead of hardcode.
|
||||
## Phase 8 — Quality Scale: Silver + selected Gold
|
||||
|
||||
## Phase 4 — Virtual devices ← ✅ basics in v1.6.x (CRUD of virtual markers in the shared dialog; further note/icon polish as needed)
|
||||
- CRUD of virtual markers (name, icon, coordinates, note; optionally a link to an
|
||||
entity/URL): septic tank, valve, a meter without a sensor, etc. Rendered like normal icons,
|
||||
click → a card with the note or more-info of the bound entity.
|
||||
- [ ] `test-coverage` ≥95% backend; frontend: extract remaining pure logic (view math,
|
||||
marker resolution) into `logic.ts`/`devices.ts` and cover with node:test.
|
||||
- [ ] `diagnostics.py`: config + layout dump with `async_redact_data` (redact names/links/PDF paths).
|
||||
- [ ] `reconfiguration-flow` + richer **options flow**: admin_only, curation defaults
|
||||
(exclude domains — UI editable, replacing the hardcoded EXCLUDED_DOMAINS fallback),
|
||||
LQI thresholds, group_lights default.
|
||||
- [ ] `repair-issues`: broken plan file references, orphaned layout entries, storage
|
||||
migration failures → Repairs UI instead of silent logs.
|
||||
- [ ] `system_health.py`: config rev, spaces/markers count, storage sizes.
|
||||
- [ ] `exception-translations` + `icon-translations` where applicable.
|
||||
- [ ] Frontend resource registration: adopt the community-consensus embedded-card pattern
|
||||
end-to-end (we already do StaticPathConfig + storage-mode resource + `?v=` busting;
|
||||
add resource cleanup on removal and document YAML-mode fallback).
|
||||
|
||||
## Phase 5 — UX/feature polish
|
||||
- Configurable click actions: toggle for lights/outlets, long-press → more-info.
|
||||
- Live-indication tuning (theme-aware colors, badges), light theme.
|
||||
- Tooltips on touch devices (long-press), accessibility (keyboard, aria).
|
||||
- LQI option: "bad signal" threshold, hiding the labels on non-zigbee instances.
|
||||
## Phase 9 — Universality & flexibility (product depth)
|
||||
|
||||
## Phase 6 — Quality and publication
|
||||
- Tests: pytest (config_flow, websocket_api, Store migrations) + hassfest/hacs action in CI;
|
||||
frontend: vitest for rules/geometry utilities.
|
||||
- Typing: strict TS interfaces for hass (custom-card-helpers or our own), mypy for python.
|
||||
- Translations for integration+card: en + ru (translations/, card string localization).
|
||||
- Quality scale bronze → silver checklist; PR into hacs/default; optionally a PR into
|
||||
home-assistant/brands (the brand/ in the repo suffices for now).
|
||||
- [ ] **Areas/floors registry integration**: import HA floors as spaces, suggest area
|
||||
bindings from the registry, sync names (HA is moving this way — native Areas/Home
|
||||
dashboard; riding the registry is our moat).
|
||||
- [ ] **Curation without hardcode**: icon rules (`iconFor`) become data — user-editable
|
||||
mapping (regex/domain/device_class → mdi icon) stored in config, shipping EN+RU
|
||||
defaults; drop dacha-specific patterns from code.
|
||||
- [ ] **Click actions** per device/domain: toggle / more-info / navigate / custom service
|
||||
call (configurable, like standard card `tap_action`).
|
||||
- [ ] **Theming**: respect light themes (currently dark-leaning), use HA theme variables
|
||||
everywhere, optional per-space background color.
|
||||
- [ ] Multi-instance question: keep single-instance (one house) but support **multiple
|
||||
cards** with different default spaces (already works) — document as a decision.
|
||||
- [ ] Plan formats: keep SVG/PNG/JPG/WebP; add max dimensions guidance; optional
|
||||
auto-downscale on upload.
|
||||
- [ ] More locales: extract i18n dictionaries to JSON so contributors can add languages
|
||||
without touching TS.
|
||||
|
||||
## Open questions
|
||||
- Publication name: "House Plan Card"? the `houseplan` domain is not taken in HACS default — verify.
|
||||
- MIT license (package.json is already MIT) — add a LICENSE file.
|
||||
- Plan formats: SVG preferred (vector, size), PNG support is mandatory.
|
||||
## Phase 10 — Community & distribution
|
||||
|
||||
- [ ] hacs/default PR #8995 through moderation (expect drafting for fixes).
|
||||
- [ ] Demo GIF/video for README (the single biggest driver of adoption for dashboard cards).
|
||||
- [ ] Forum post in the Floorplan category + Reddit r/homeassistant showcase once
|
||||
the demo assets exist.
|
||||
- [ ] GitHub: issue templates, discussions on, CONTRIBUTING.md (build/test in 5 minutes).
|
||||
- [ ] Semantic versioning discipline; keep release notes user-facing (they show in HACS).
|
||||
|
||||
## Explicit non-goals (for now)
|
||||
|
||||
- 3D plans (.glb) — separate market (3Dash), heavy dependencies.
|
||||
- Editing the plan image itself (walls/furniture drawing à la easy-floorplan) — we
|
||||
consume existing plan images; the markup editor stays about *rooms/zones*, not drawing.
|
||||
- Cloud anything. Config stays in `.storage`, local-first.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Project status & session context
|
||||
|
||||
> **Purpose of this file.** Cowork/AI sessions lose context (overflow, new session).
|
||||
> This file is the **first thing to read** when resuming work. It captures the current
|
||||
> state, where everything lives, and how to continue safely.
|
||||
>
|
||||
> **Documentation policy (mandatory):** every change is documented *in the same
|
||||
> commit* — CHANGELOG entry for anything user-visible, STATUS.md for state changes
|
||||
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
|
||||
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
|
||||
|
||||
## Snapshot (2026-07-05)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.13.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 | 28 frontend (node:test) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
||||
|
||||
## Recent milestones (details in CHANGELOG.md)
|
||||
|
||||
- **v1.10.0** — audit & refactor: asyncio.Lock around all store writes (race fix, atomic
|
||||
`expected_rev`), point `layout/update` instead of full `layout/set` (anti last-writer-wins),
|
||||
new `layout/delete`, `safeUrl()` XSS guard, `fetchWithAuth`, KEY_HASS, streaming upload cap,
|
||||
card split into modules (`styles.ts` / `types.ts` / `devices.ts`), dynamic spaces in the GUI
|
||||
editor, dead code removed.
|
||||
- **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.
|
||||
- **v1.13.0** — universality: floors-import wizard, editable icon rules (+device_class
|
||||
fallback), tap actions with a security model, i18n dictionaries in JSON, light-theme pass.
|
||||
|
||||
## Where things live
|
||||
|
||||
- **Source of truth:** the git repo (GitHub `main`). In a sandbox session: clone from GitHub or
|
||||
from `houseplan-card.git.bundle` (kept fresh in the user folder root *and* in `houseplan-card/`).
|
||||
- **User folder** `houseplan/houseplan-card/` — a file mirror of the repo (synced after every
|
||||
commit; the mount cannot delete files, so a few stale artifacts linger — git is authoritative).
|
||||
- **Production config:** server-side on the HA instance, `.storage/houseplan.config` +
|
||||
`.storage/houseplan.layout` (backups `.bak-v1100` exist on the box).
|
||||
|
||||
## Open items / watchlist
|
||||
|
||||
1. **hacs/default PR #8995** — waiting for moderation (bot may draft the PR for minor fixes —
|
||||
fix and re-ready; wrongly filled template ⇒ silent close).
|
||||
2. GitHub PAT `houseplan-card-publish` (repo+workflow) expires ~2026-07-12; in sandbox
|
||||
`~/.git-credentials`. Revoke after the HACS queue clears, or re-issue when needed.
|
||||
3. Stale files on the mount that cannot be deleted from the sandbox: `src/data/` leftovers,
|
||||
`brand_preview.png`, old nested bundle copies — ignore, git is authoritative.
|
||||
4. Roadmap next: see ROADMAP.md (quality-scale conformance is the next big theme).
|
||||
|
||||
## How to resume work in a fresh session (checklist)
|
||||
|
||||
1. Read this file, then CHANGELOG.md (top entries), DEVELOPMENT.md (environment gotchas).
|
||||
2. Restore the repo: `git clone <user-folder>/houseplan-card.git.bundle hpcN` in `/tmp`
|
||||
(files from *previous* sandbox sessions in `/tmp` belong to `nobody` and are unreadable —
|
||||
always clone into a fresh directory; `npm ci` again).
|
||||
3. Deployment needs the `ha_jb` SSH key — ask the user to upload it (uploads are readable
|
||||
only in the session they were uploaded in).
|
||||
4. Build only in `/tmp` (never on the mount), `npm run build` (starts with `tsc --noEmit`),
|
||||
md5-verify after every deploy, restart HA via
|
||||
`nohup ha core restart >/dev/null 2>&1 </dev/null &` (otherwise the SSH session hangs).
|
||||
5. GitHub pushes need a PAT (create via the user's Chrome: settings/tokens, repo+workflow scope).
|
||||
Generated
+24
-2
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.0.0",
|
||||
"version": "1.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.0.0",
|
||||
"version": "1.12.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@rollup/plugin-typescript": "^11.1.6",
|
||||
@@ -85,6 +86,27 @@
|
||||
"@lit-labs/ssr-dom-shim": "^1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-json": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
|
||||
"integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rollup/pluginutils": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-node-resolve": {
|
||||
"version": "15.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.11.2",
|
||||
"version": "1.13.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -11,9 +11,10 @@
|
||||
"test": "tsc -p tsconfig.test.json && node --test test/*.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-typescript": "^11.1.6",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@rollup/plugin-typescript": "^11.1.6",
|
||||
"rollup": "^4.18.0",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.4.5"
|
||||
@@ -21,4 +22,4 @@
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests_backend
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import typescript from '@rollup/plugin-typescript';
|
||||
import json from '@rollup/plugin-json';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
|
||||
@@ -9,5 +10,5 @@ export default {
|
||||
format: 'es',
|
||||
sourcemap: false,
|
||||
},
|
||||
plugins: [resolve(), typescript(), terser({ format: { comments: false } })],
|
||||
plugins: [resolve(), json(), typescript(), terser({ format: { comments: false } })],
|
||||
};
|
||||
|
||||
+21
-4
@@ -2,7 +2,7 @@
|
||||
* Building the device list from HA registries: curation, light groups,
|
||||
* markers (overrides/virtual). No Lit/DOM — only the hass object.
|
||||
*/
|
||||
import { iconFor, DOMAIN_PRIORITY } from './rules';
|
||||
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule } from './rules';
|
||||
import { averageLqi } from './logic';
|
||||
import type { DevItem, Marker, ServerConfig } from './types';
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface BuildCtx {
|
||||
firstSpaceId: string;
|
||||
/** Localized display strings for generated device names. */
|
||||
loc: (key: 'device.unnamed' | 'device.light_group' | 'device.fallback' | 'device.virtual') => string;
|
||||
/** Compiled icon rules (instance overrides or built-in defaults). */
|
||||
iconRules?: CompiledIconRule[];
|
||||
}
|
||||
|
||||
export function entitiesByDevice(hass: any): Record<string, string[]> {
|
||||
@@ -121,6 +123,18 @@ export function lightGroups(hass: any, enabled: boolean): { eid: string; name: s
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Icon with the full fallback chain: name rules → entity device_class → chip. */
|
||||
function resolveIcon(hass: any, name: string, model: string | undefined, entIds: string[], rules?: CompiledIconRule[]): string {
|
||||
const byRules = iconFor(name, model, rules);
|
||||
if (byRules !== FALLBACK_ICON) return byRules;
|
||||
const classes: string[] = [];
|
||||
for (const eid of entIds) {
|
||||
const dc = hass.states[eid]?.attributes?.device_class;
|
||||
if (dc) classes.push(dc);
|
||||
}
|
||||
return iconFromDeviceClasses(classes) ?? FALLBACK_ICON;
|
||||
}
|
||||
|
||||
function applyMarker(item: DevItem, m: Marker): void {
|
||||
item.marker = m;
|
||||
if (m.name) item.name = m.name;
|
||||
@@ -129,11 +143,12 @@ function applyMarker(item: DevItem, m: Marker): void {
|
||||
item.link = m.link ?? null;
|
||||
item.description = m.description ?? null;
|
||||
item.pdfs = m.pdfs || [];
|
||||
item.tapAction = m.tap_action ?? null;
|
||||
}
|
||||
|
||||
/** Curation + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
|
||||
export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc } = ctx;
|
||||
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
|
||||
const groupLights = settings.group_lights !== false;
|
||||
const groups = lightGroups(h, groupLights);
|
||||
const groupedAreas = new Set(groups.map((g) => g.area));
|
||||
@@ -167,7 +182,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
}
|
||||
const name = (dev.name_by_user || dev.name || loc('device.unnamed')).trim();
|
||||
const key = name + '|' + area;
|
||||
let icon = iconFor(name, dev.model);
|
||||
let icon = resolveIcon(h, name, dev.model, entIds, iconRules);
|
||||
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
|
||||
if (!showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
|
||||
// duplicates by “name|zone” are numbered rather than hidden
|
||||
@@ -218,7 +233,9 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
const area = m.area || dev?.area_id || '';
|
||||
const space = (area && areaToSpace[area]) || m.space || firstSpaceId;
|
||||
const entIds = dev ? entsBy[dev.id] || [] : [];
|
||||
let icon = dev ? iconFor(dev.name_by_user || dev.name || '', dev.model) : 'mdi:help-circle';
|
||||
let icon = dev
|
||||
? resolveIcon(h, dev.name_by_user || dev.name || '', dev.model, entIds, iconRules)
|
||||
: 'mdi:help-circle';
|
||||
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
|
||||
const item: DevItem = {
|
||||
id: m.id,
|
||||
|
||||
@@ -63,6 +63,19 @@ class HouseplanCardEditor extends LitElement {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tap_action',
|
||||
selector: {
|
||||
select: {
|
||||
mode: 'dropdown',
|
||||
options: [
|
||||
{ value: 'info', label: t(L, 'tap.info') },
|
||||
{ value: 'more-info', label: t(L, 'tap.more_info') },
|
||||
{ value: 'toggle', label: t(L, 'tap.toggle') },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
|
||||
{ name: 'show_temperature', selector: { boolean: {} } },
|
||||
{ name: 'live_states', selector: { boolean: {} } },
|
||||
@@ -78,6 +91,7 @@ class HouseplanCardEditor extends LitElement {
|
||||
title: t(L, 'editor.title'),
|
||||
default_floor: t(L, 'editor.default_floor'),
|
||||
language: t(L, 'editor.language'),
|
||||
tap_action: t(L, 'editor.tap_action'),
|
||||
icon_size: t(L, 'editor.icon_size'),
|
||||
show_temperature: t(L, 'editor.show_temperature'),
|
||||
live_states: t(L, 'editor.live_states'),
|
||||
|
||||
+276
-11
@@ -7,10 +7,13 @@
|
||||
* The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage.
|
||||
*/
|
||||
import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit';
|
||||
import { EXCLUDED_DOMAINS } from './rules';
|
||||
import {
|
||||
EXCLUDED_DOMAINS, DEFAULT_ICON_RULES, compileIconRules, isValidPattern, iconFor,
|
||||
type IconRule, type CompiledIconRule,
|
||||
} from './rules';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
|
||||
averageLqi, fitView, declump, safeUrl,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
} from './logic';
|
||||
import { buildDevices, lqiFor, tempFor } from './devices';
|
||||
import type {
|
||||
@@ -20,7 +23,7 @@ import './editor';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.11.2';
|
||||
const CARD_VERSION = '1.13.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';
|
||||
@@ -89,6 +92,13 @@ class HouseplanCard extends LitElement {
|
||||
private _roViewport?: ResizeObserver;
|
||||
private _onboardingShown = false; // the auto space dialog is shown once per session
|
||||
|
||||
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
|
||||
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
|
||||
private _importQueue: string[] = []; // floor titles still to create
|
||||
private _importTotal = 0;
|
||||
private _rulesCompiledSrc = '';
|
||||
private _rulesCompiled: CompiledIconRule[] | undefined;
|
||||
|
||||
private _infoCard: DevItem | null = null;
|
||||
private _markerDialog: {
|
||||
devId?: string; // the icon being edited (if any)
|
||||
@@ -96,6 +106,7 @@ class HouseplanCard extends LitElement {
|
||||
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual'
|
||||
bindingFilter: string;
|
||||
icon: string; // '' = auto
|
||||
tapAction: string; // '' = card default
|
||||
model: string;
|
||||
link: string;
|
||||
description: string;
|
||||
@@ -114,6 +125,8 @@ class HouseplanCard extends LitElement {
|
||||
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
|
||||
|
||||
private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
|
||||
private _holdTimer?: number;
|
||||
private _holdFired = false;
|
||||
|
||||
static properties = {
|
||||
hass: { attribute: false },
|
||||
@@ -134,6 +147,8 @@ class HouseplanCard extends LitElement {
|
||||
_roomDialog: { state: true },
|
||||
_spaceDialog: { state: true },
|
||||
_infoCard: { state: true },
|
||||
_rulesDialog: { state: true },
|
||||
_importDialog: { state: true },
|
||||
_markerDialog: { state: true },
|
||||
_zoom: { state: true },
|
||||
_view: { state: true },
|
||||
@@ -146,6 +161,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
window.removeEventListener('keydown', this._keyHandler);
|
||||
clearTimeout(this._holdTimer);
|
||||
this._roViewport?.disconnect();
|
||||
this._roViewport = undefined;
|
||||
if (this._unsubCfg) {
|
||||
@@ -307,6 +323,18 @@ class HouseplanCard extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/** Compiled icon rules: instance settings override the built-in defaults. */
|
||||
private get _iconRules(): CompiledIconRule[] | undefined {
|
||||
const custom = this._settings.icon_rules;
|
||||
if (!custom || !Array.isArray(custom) || !custom.length) return undefined;
|
||||
const src = JSON.stringify(custom);
|
||||
if (src !== this._rulesCompiledSrc) {
|
||||
this._rulesCompiledSrc = src;
|
||||
this._rulesCompiled = compileIconRules(custom);
|
||||
}
|
||||
return this._rulesCompiled;
|
||||
}
|
||||
|
||||
private get _excluded(): Set<string> {
|
||||
const list = this._settings.exclude_integrations;
|
||||
return list ? new Set(list) : EXCLUDED_DOMAINS;
|
||||
@@ -334,10 +362,16 @@ class HouseplanCard extends LitElement {
|
||||
this._loadOk &&
|
||||
this._model.length === 0 &&
|
||||
!this._spaceDialog &&
|
||||
!this._importDialog &&
|
||||
!this._onboardingShown
|
||||
) {
|
||||
this._onboardingShown = true;
|
||||
this._openSpaceDialog('create');
|
||||
const floors = floorsOf(this.hass);
|
||||
if (floors.length) {
|
||||
this._importDialog = { floors: floors.map((f) => ({ ...f, checked: true })) };
|
||||
} else {
|
||||
this._openSpaceDialog('create');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,6 +476,7 @@ class HouseplanCard extends LitElement {
|
||||
showAll: this._showAll,
|
||||
firstSpaceId: this._model[0]?.id || '',
|
||||
loc: (k) => this._t(k),
|
||||
iconRules: this._iconRules,
|
||||
});
|
||||
this._defPos = this._defaultPositions();
|
||||
}
|
||||
@@ -580,8 +615,20 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _clickDevice(ev: MouseEvent, d: DevItem): void {
|
||||
ev.stopPropagation();
|
||||
if (this._drag?.moved || this._suppressClick) return;
|
||||
if (this._drag?.moved || this._suppressClick || this._holdFired) return;
|
||||
if (this._markup) return;
|
||||
const domain = d.primary ? d.primary.split('.')[0] : null;
|
||||
const action = resolveTapAction(d.tapAction, this._config?.tap_action, domain);
|
||||
if (action === 'toggle' && d.primary) {
|
||||
this.hass
|
||||
.callService('homeassistant', 'toggle', { entity_id: d.primary })
|
||||
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
|
||||
return;
|
||||
}
|
||||
if (action === 'more-info' && d.primary) {
|
||||
this._openMoreInfo(d.primary);
|
||||
return;
|
||||
}
|
||||
this._infoCard = d;
|
||||
}
|
||||
|
||||
@@ -785,6 +832,17 @@ class HouseplanCard extends LitElement {
|
||||
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
|
||||
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
this._tip = null;
|
||||
// long-press always opens the info card — keeps metadata reachable
|
||||
// even when the tap action is set to toggle
|
||||
this._holdFired = false;
|
||||
clearTimeout(this._holdTimer);
|
||||
this._holdTimer = window.setTimeout(() => {
|
||||
if (this._drag && this._drag.id === d.id && !this._drag.moved) {
|
||||
this._holdFired = true;
|
||||
this._drag = null;
|
||||
this._infoCard = d;
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
|
||||
private _pointerMove(ev: PointerEvent, d: DevItem): void {
|
||||
@@ -796,7 +854,10 @@ class HouseplanCard extends LitElement {
|
||||
const v = this._viewOr(vb);
|
||||
const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w;
|
||||
const dy = ((ev.clientY - this._drag.sy) / rect.height) * v.h;
|
||||
if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) this._drag.moved = true;
|
||||
if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) {
|
||||
this._drag.moved = true;
|
||||
clearTimeout(this._holdTimer);
|
||||
}
|
||||
const m = Math.min(vb[2], vb[3]) * 0.008;
|
||||
const nx = Math.max(vb[0] + m, Math.min(vb[0] + vb[2] - m, this._drag.ox + dx));
|
||||
const ny = Math.max(vb[1] + m, Math.min(vb[1] + vb[3] - m, this._drag.oy + dy));
|
||||
@@ -804,6 +865,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _pointerUp(_ev: PointerEvent, d: DevItem): void {
|
||||
clearTimeout(this._holdTimer);
|
||||
if (!this._drag || this._drag.id !== d.id) return;
|
||||
const moved = this._drag.moved;
|
||||
this._drag = moved ? this._drag : null;
|
||||
@@ -1103,6 +1165,7 @@ class HouseplanCard extends LitElement {
|
||||
binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef,
|
||||
bindingFilter: '',
|
||||
icon: d.marker?.icon || '',
|
||||
tapAction: d.marker?.tap_action || '',
|
||||
model: d.model || '',
|
||||
link: d.link || '',
|
||||
description: d.description || '',
|
||||
@@ -1112,7 +1175,7 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
} else {
|
||||
this._markerDialog = {
|
||||
name: '', binding: 'virtual', bindingFilter: '', icon: '', model: '',
|
||||
name: '', binding: 'virtual', bindingFilter: '', icon: '', tapAction: '', model: '',
|
||||
link: '', description: '', pdfs: [], room: '', busy: false,
|
||||
};
|
||||
}
|
||||
@@ -1275,6 +1338,7 @@ class HouseplanCard extends LitElement {
|
||||
binding: dlg.binding,
|
||||
name: dlg.name.trim() || null,
|
||||
icon: dlg.icon || null,
|
||||
tap_action: dlg.tapAction || null,
|
||||
model: dlg.model.trim() || null,
|
||||
link: dlg.link.trim() || null,
|
||||
description: dlg.description.trim() || null,
|
||||
@@ -1451,13 +1515,18 @@ class HouseplanCard extends LitElement {
|
||||
if (d.mode === 'create') this._space = sp.id;
|
||||
this._regSignature = '';
|
||||
this._maybeRebuildDevices();
|
||||
if (wasFirst) {
|
||||
if (this._importQueue.length) {
|
||||
// floors-import wizard: proceed to the next floor
|
||||
this._openNextImport();
|
||||
} else if (wasFirst || this._importTotal > 0) {
|
||||
// guide the user onward: straight into room markup mode
|
||||
this._importTotal = 0;
|
||||
this._space = this._serverCfg!.spaces[0]?.id || this._space;
|
||||
this._markup = true;
|
||||
this._tool = 'draw';
|
||||
this._path = [];
|
||||
this._cursorPt = null;
|
||||
this._showToast(this._t('toast.space_added_onboard'));
|
||||
this._showToast(this._t(wasFirst && !this._importTotal ? 'toast.space_added_onboard' : 'import.done'));
|
||||
} else {
|
||||
this._showToast(d.mode === 'create' ? this._t('toast.space_added') : this._t('toast.space_saved'));
|
||||
}
|
||||
@@ -1494,6 +1563,179 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
|
||||
// ================= FLOORS IMPORT WIZARD =================
|
||||
|
||||
private _startImport(): void {
|
||||
const dlg = this._importDialog;
|
||||
if (!dlg) return;
|
||||
const titles = dlg.floors.filter((f) => f.checked).map((f) => f.name);
|
||||
this._importDialog = null;
|
||||
if (!titles.length) {
|
||||
this._openSpaceDialog('create');
|
||||
return;
|
||||
}
|
||||
this._importQueue = titles;
|
||||
this._importTotal = titles.length;
|
||||
this._openNextImport();
|
||||
}
|
||||
|
||||
/** Open the space dialog for the next queued floor (title prefilled, plan required). */
|
||||
private _openNextImport(): void {
|
||||
const title = this._importQueue.shift();
|
||||
if (title === undefined) return;
|
||||
this._spaceDialog = { mode: 'create', title, planUrl: null, planFile: null, busy: false };
|
||||
}
|
||||
|
||||
/** Skip the current floor of the wizard without creating a space. */
|
||||
private _skipImport(): void {
|
||||
this._spaceDialog = null;
|
||||
if (this._importQueue.length) this._openNextImport();
|
||||
else if (this._importTotal > 0 && this._model.length) {
|
||||
this._importTotal = 0;
|
||||
this._space = this._serverCfg!.spaces[0]?.id || this._space;
|
||||
this._markup = true;
|
||||
this._showToast(this._t('import.done'));
|
||||
}
|
||||
}
|
||||
|
||||
private _renderImportDialog(): TemplateResult {
|
||||
const d = this._importDialog!;
|
||||
const n = d.floors.filter((f) => f.checked).length;
|
||||
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="hd"><ha-icon icon="mdi:home-floor-1"></ha-icon>${this._t('import.title')}</div>
|
||||
<div class="body">
|
||||
<div class="rhint">${this._t('import.hint')}</div>
|
||||
${d.floors.map(
|
||||
(f, i) => html`<label class="floorrow">
|
||||
<input type="checkbox" .checked=${f.checked}
|
||||
@change=${(e: Event) => {
|
||||
const floors = [...d.floors];
|
||||
floors[i] = { ...f, checked: (e.target as HTMLInputElement).checked };
|
||||
this._importDialog = { floors };
|
||||
}} />
|
||||
<span>${f.name}</span>
|
||||
${f.level != null ? html`<span class="floorlvl">L${f.level}</span>` : nothing}
|
||||
</label>`,
|
||||
)}
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn ghost" @click=${() => { this._importDialog = null; this._openSpaceDialog('create'); }}>
|
||||
${this._t('import.manual')}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn on" @click=${() => this._startImport()} ?disabled=${!n}>
|
||||
<ha-icon icon="mdi:import"></ha-icon>${this._t('import.start', { n })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ================= ICON RULES EDITOR =================
|
||||
|
||||
private _openRulesDialog = (): void => {
|
||||
if (!this._norm) return;
|
||||
const custom = this._settings.icon_rules;
|
||||
const rules = (custom && custom.length ? custom : DEFAULT_ICON_RULES).map((r) => ({ ...r }));
|
||||
this._rulesDialog = { rules, test: '', busy: false };
|
||||
};
|
||||
|
||||
private _rulesSet(rules: IconRule[]): void {
|
||||
this._rulesDialog = { ...this._rulesDialog!, rules };
|
||||
}
|
||||
|
||||
private async _saveRules(): Promise<void> {
|
||||
const dlg = this._rulesDialog;
|
||||
if (!dlg || dlg.busy) return;
|
||||
const cleaned = dlg.rules.filter((r) => r.pattern.trim() && r.icon.trim());
|
||||
this._rulesDialog = { ...dlg, busy: true };
|
||||
try {
|
||||
const cfg = this._serverCfg!;
|
||||
const isDefault = JSON.stringify(cleaned) === JSON.stringify(DEFAULT_ICON_RULES);
|
||||
const settings: any = { ...cfg.settings };
|
||||
if (isDefault) delete settings.icon_rules;
|
||||
else settings.icon_rules = cleaned;
|
||||
this._serverCfg = { ...cfg, settings };
|
||||
await this._saveConfigNow();
|
||||
this._rulesDialog = null;
|
||||
this._regSignature = '';
|
||||
this._maybeRebuildDevices();
|
||||
this._showToast(this._t('rules.saved'));
|
||||
} catch (e: any) {
|
||||
this._rulesDialog = { ...this._rulesDialog!, busy: false };
|
||||
this._showToast(this._t('toast.error', { err: this._errText(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
private _renderRulesDialog(): TemplateResult {
|
||||
const d = this._rulesDialog!;
|
||||
const compiled = compileIconRules(d.rules);
|
||||
const testIcon = d.test.trim() ? iconFor(d.test, '', compiled) : null;
|
||||
const move = (i: number, delta: number) => {
|
||||
const r = [...d.rules];
|
||||
const j = i + delta;
|
||||
if (j < 0 || j >= r.length) return;
|
||||
[r[i], r[j]] = [r[j], r[i]];
|
||||
this._rulesSet(r);
|
||||
};
|
||||
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="hd"><ha-icon icon="mdi:shape-plus-outline"></ha-icon>${this._t('rules.title')}</div>
|
||||
<div class="body">
|
||||
<div class="rhint">${this._t('rules.hint')}</div>
|
||||
<div class="rtest">
|
||||
<input class="namein" type="text" placeholder=${this._t('rules.test_ph')}
|
||||
.value=${d.test}
|
||||
@input=${(e: Event) => (this._rulesDialog = { ...d, test: (e.target as HTMLInputElement).value })} />
|
||||
${testIcon ? html`<ha-icon icon=${testIcon}></ha-icon><span class="rtesticon">${testIcon}</span>` : nothing}
|
||||
</div>
|
||||
${d.rules.map((r, i) => {
|
||||
const bad = r.pattern.trim() !== '' && !isValidPattern(r.pattern);
|
||||
return html`<div class="rrow">
|
||||
<input class="namein rpat ${bad ? 'bad' : ''}" type="text"
|
||||
placeholder=${this._t('rules.pattern_ph')}
|
||||
title=${bad ? this._t('rules.invalid') : ''}
|
||||
.value=${r.pattern}
|
||||
@input=${(e: Event) => {
|
||||
const rules = [...d.rules];
|
||||
rules[i] = { ...r, pattern: (e.target as HTMLInputElement).value };
|
||||
this._rulesSet(rules);
|
||||
}} />
|
||||
<input class="namein ricon" type="text" placeholder=${this._t('rules.icon_ph')}
|
||||
.value=${r.icon}
|
||||
@input=${(e: Event) => {
|
||||
const rules = [...d.rules];
|
||||
rules[i] = { ...r, icon: (e.target as HTMLInputElement).value };
|
||||
this._rulesSet(rules);
|
||||
}} />
|
||||
<ha-icon class="rprev" icon=${r.icon || 'mdi:chip'}></ha-icon>
|
||||
<ha-icon class="ract" icon="mdi:arrow-up" title=${this._t('btn.up')}
|
||||
@click=${() => move(i, -1)}></ha-icon>
|
||||
<ha-icon class="ract" icon="mdi:arrow-down" title=${this._t('btn.down')}
|
||||
@click=${() => move(i, 1)}></ha-icon>
|
||||
<ha-icon class="ract del" icon="mdi:close" title=${this._t('btn.delete')}
|
||||
@click=${() => this._rulesSet(d.rules.filter((_, j) => j !== i))}></ha-icon>
|
||||
</div>`;
|
||||
})}
|
||||
<button class="btn ghost" @click=${() => this._rulesSet([...d.rules, { pattern: '', icon: '' }])}>
|
||||
<ha-icon icon="mdi:plus"></ha-icon>${this._t('rules.add')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn ghost" @click=${() => this._rulesSet(DEFAULT_ICON_RULES.map((r) => ({ ...r })))}>
|
||||
${this._t('rules.reset')}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn ghost" @click=${() => (this._rulesDialog = null)}>${this._t('btn.cancel')}</button>
|
||||
<button class="btn on" @click=${this._saveRules} ?disabled=${d.busy}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this._t('btn.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ================= render =================
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
@@ -1515,6 +1757,7 @@ class HouseplanCard extends LitElement {
|
||||
: html`<p class="muted">${this._t('empty.install')}</p>`}
|
||||
</div>
|
||||
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
|
||||
${this._importDialog ? this._renderImportDialog() : nothing}
|
||||
${this._toast ? html`<div class="toast">${this._toast}</div>` : nothing}
|
||||
</ha-card>`;
|
||||
}
|
||||
@@ -1581,6 +1824,9 @@ class HouseplanCard extends LitElement {
|
||||
</button>
|
||||
<button class="btn" @click=${this._resetLayout} title=${this._t('title.reset_layout')}>
|
||||
<ha-icon icon="mdi:backup-restore"></ha-icon>
|
||||
</button>
|
||||
<button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}>
|
||||
<ha-icon icon="mdi:shape-plus-outline"></ha-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
<button class="btn ${this._markup ? 'on' : ''}" @click=${this._toggleMarkup}
|
||||
@@ -1637,6 +1883,8 @@ class HouseplanCard extends LitElement {
|
||||
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
|
||||
${this._markerDialog ? this._renderMarkerDialog() : nothing}
|
||||
${this._infoCard ? this._renderInfoCard() : nothing}
|
||||
${this._rulesDialog ? this._renderRulesDialog() : nothing}
|
||||
${this._importDialog ? this._renderImportDialog() : nothing}
|
||||
${this._tip
|
||||
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
|
||||
<b>${this._tip.title}</b>${this._tip.meta ? html`<span class="m">${this._tip.meta}</span>` : nothing}
|
||||
@@ -1837,6 +2085,14 @@ class HouseplanCard extends LitElement {
|
||||
)}
|
||||
</select>
|
||||
|
||||
<label>${this._t('marker.tap_label')}</label>
|
||||
<select class="areasel"
|
||||
@change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}>
|
||||
${[['', 'tap.auto'], ['info', 'tap.info'], ['more-info', 'tap.more_info'], ['toggle', 'tap.toggle']].map(
|
||||
([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || '') === v}>${this._t(k as any)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
|
||||
<label>${this._t('marker.icon_label')}</label>
|
||||
${customElements.get('ha-icon-picker')
|
||||
? html`<ha-icon-picker .hass=${this.hass} .value=${d.icon}
|
||||
@@ -1895,7 +2151,13 @@ class HouseplanCard extends LitElement {
|
||||
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="hd"><ha-icon icon="mdi:floor-plan"></ha-icon>
|
||||
${d.mode === 'create' ? this._t('space.new') : this._t('space.header')}</div>
|
||||
${d.mode === 'create' ? this._t('space.new') : this._t('space.header')}
|
||||
${this._importTotal > 0 && d.mode === 'create'
|
||||
? html`<span class="importprog">${this._t('import.progress', {
|
||||
i: this._importTotal - this._importQueue.length,
|
||||
n: this._importTotal,
|
||||
})}</span>`
|
||||
: nothing}</div>
|
||||
<div class="body">
|
||||
<label>${this._t('space.title_label')}</label>
|
||||
<input class="namein" type="text" placeholder=${this._t('space.title_ph')}
|
||||
@@ -1922,7 +2184,10 @@ class HouseplanCard extends LitElement {
|
||||
</button>`
|
||||
: nothing}
|
||||
<span class="spacer"></span>
|
||||
<button class="btn ghost" @click=${() => (this._spaceDialog = null)}>${this._t('btn.cancel')}</button>
|
||||
${this._importTotal > 0 && d.mode === 'create'
|
||||
? html`<button class="btn ghost" @click=${() => this._skipImport()}>${this._t('btn.skip')}</button>`
|
||||
: nothing}
|
||||
<button class="btn ghost" @click=${() => { this._spaceDialog = null; this._importQueue = []; this._importTotal = 0; }}>${this._t('btn.cancel')}</button>
|
||||
<button class="btn on" @click=${this._saveSpaceDialog}
|
||||
?disabled=${!d.title.trim() || !(d.planFile || d.planUrl) || d.busy}
|
||||
title=${!(d.planFile || d.planUrl) ? this._t('title.need_plan') : ''}>
|
||||
|
||||
+9
-294
@@ -1,306 +1,21 @@
|
||||
/**
|
||||
* Card UI localization. Language is resolved from the card config
|
||||
* (`language: en|ru`) or, by default, from the HA user profile
|
||||
* (hass.locale.language); anything that is not Russian falls back to English.
|
||||
* Card UI localization. Dictionaries live in src/i18n/<lang>.json so that new
|
||||
* languages can be contributed without touching TypeScript. The language is
|
||||
* resolved from the card config (`language: en|ru`) or, by default, from the
|
||||
* HA user profile (hass.locale.language); anything that is not a known
|
||||
* language falls back to English.
|
||||
*/
|
||||
import en from './i18n/en.json';
|
||||
import ru from './i18n/ru.json';
|
||||
|
||||
export type Lang = 'en' | 'ru';
|
||||
|
||||
const en = {
|
||||
'card.title': 'House plan',
|
||||
'count.devices': '{n} dev.',
|
||||
|
||||
'empty.no_spaces': 'No spaces yet.',
|
||||
'empty.add_first': 'Add the first space and upload a floor plan.',
|
||||
'empty.install': 'Install the House Plan integration and add it in "Devices & services".',
|
||||
|
||||
'btn.add_space': 'Add space',
|
||||
'btn.cancel': 'Cancel',
|
||||
'btn.save': 'Save',
|
||||
'btn.close': 'Close',
|
||||
'btn.delete': 'Delete',
|
||||
'btn.remove': 'Remove',
|
||||
'btn.edit': 'Edit',
|
||||
'btn.open_in_ha': 'Open in HA',
|
||||
'btn.reset': 'Reset',
|
||||
'btn.attach': 'Attach…',
|
||||
'btn.upload': 'Upload…',
|
||||
'btn.replace': 'Replace…',
|
||||
'btn.no_area': 'No area',
|
||||
|
||||
'title.zoom_in': 'Zoom in',
|
||||
'title.zoom_out': 'Zoom out',
|
||||
'title.zoom_reset': 'Reset zoom',
|
||||
'title.add_device': 'Add a device to the plan',
|
||||
'title.show_all': 'Show all area devices (no curation)',
|
||||
'title.reset_layout': 'Reset icon positions to auto layout',
|
||||
'title.markup': 'Room markup: grid, lines, outlines',
|
||||
'title.configure_space': 'Configure space',
|
||||
'title.add_space': 'Add space',
|
||||
'title.markup_add': 'Add a room: connect grid dots with lines until the outline closes',
|
||||
'title.markup_erase': 'Erase a line: click the line',
|
||||
'title.markup_delroom': 'Delete a room: click inside the room',
|
||||
'title.no_area_room': 'Decorative room without an HA area (e.g. a hallway)',
|
||||
'title.choose_area': 'Select a Home Assistant area',
|
||||
'title.need_plan': 'Upload a floor-plan image',
|
||||
|
||||
'markup.add': 'Add',
|
||||
'markup.erase': 'Erase',
|
||||
'markup.delete': 'Delete',
|
||||
'markup.hint_points': 'points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one',
|
||||
'markup.hint_start': 'click a grid dot to start the outline',
|
||||
|
||||
'tip.room': 'room — open the area',
|
||||
'tip.lqi': 'average zigbee signal:',
|
||||
|
||||
'info.device_header': 'Device on the plan',
|
||||
'info.model': 'Model',
|
||||
'info.state': 'State',
|
||||
'info.link': 'Link',
|
||||
'info.manuals': 'Manuals',
|
||||
'info.none': 'No additional information',
|
||||
|
||||
'marker.new_device': 'New device',
|
||||
'marker.name_label': 'Name (shown on the plan)',
|
||||
'marker.name_ph': 'Name',
|
||||
'marker.binding_label': 'Bind to an HA device',
|
||||
'marker.virtual_option': 'Virtual device (no binding)',
|
||||
'marker.search_ph': 'Search device / group…',
|
||||
'marker.nothing_found': 'nothing found',
|
||||
'marker.room_label': 'Room',
|
||||
'marker.room_override': ' (override placement)',
|
||||
'marker.room_choose': '— select a room —',
|
||||
'marker.room_auto': '— by device area (auto) —',
|
||||
'marker.icon_label': 'Icon',
|
||||
'marker.icon_ph': 'mdi:… (empty = auto)',
|
||||
'marker.model_label': 'Model',
|
||||
'marker.model_ph': 'e.g. Aqara T&H',
|
||||
'marker.link_label': 'Link',
|
||||
'marker.desc_label': 'Description',
|
||||
'marker.desc_ph': 'Notes, specs…',
|
||||
'marker.manuals_label': 'Manuals (PDF etc.)',
|
||||
'marker.sub_device': 'device',
|
||||
'marker.sub_z2m_group': ' · Z2M group',
|
||||
'marker.sub_group': 'group',
|
||||
'marker.sub_helper': 'helper',
|
||||
|
||||
'space.new': 'New space',
|
||||
'space.header': 'Space',
|
||||
'space.title_label': 'Title',
|
||||
'space.title_ph': 'e.g. Garage',
|
||||
'space.plan_label': 'Floor plan (background)',
|
||||
'space.no_plan': 'no plan image',
|
||||
'space.plan_alt': 'plan',
|
||||
|
||||
'room.new': 'New room',
|
||||
'room.name_label': 'Display name',
|
||||
'room.name_ph': 'e.g. Terrace',
|
||||
'room.area_label': 'Home Assistant area (unassigned)',
|
||||
'room.no_area_option': '— no area —',
|
||||
'room.default_name': 'Room',
|
||||
|
||||
'device.unnamed': 'unnamed',
|
||||
'device.light_group': 'light group',
|
||||
'device.fallback': 'device',
|
||||
'device.virtual': 'virtual device',
|
||||
|
||||
'confirm.reset_layout': 'Reset all icon positions to the auto layout?',
|
||||
'confirm.delete_room': 'Delete room "{name}"?',
|
||||
'confirm.remove_marker': 'Remove "{name}" from the plan?',
|
||||
'confirm.delete_space': 'Delete space "{title}" with all its rooms and markup?',
|
||||
|
||||
'toast.pos_save_failed': 'Failed to save position: {err}',
|
||||
'toast.no_entity': 'The device has no suitable entity',
|
||||
'toast.markup_needs_server': 'Markup is available after the config is moved to the server',
|
||||
'toast.conflict': 'Config was changed in another window — data refreshed, repeat your last action',
|
||||
'toast.cfg_save_failed': 'Failed to save config: {err}',
|
||||
'toast.room_saved': 'Room saved ({n}). Devices added: {added}. Outline the next one or exit markup.',
|
||||
'toast.room_saved_no_area': 'Room saved ({n}, no area). Outline the next one or exit markup.',
|
||||
'toast.marker_needs_server': 'Device editing is available after the config is moved to the server',
|
||||
'toast.virtual_name_required': 'Enter a name for the virtual device',
|
||||
'toast.marker_saved': 'Device saved',
|
||||
'toast.marker_removed': 'Device removed from the plan',
|
||||
'toast.integration_missing': 'The House Plan integration is not installed — management unavailable',
|
||||
'toast.plan_formats': 'Supported formats: SVG, PNG, JPG, WebP',
|
||||
'toast.plan_required': 'Upload a floor plan — it is required',
|
||||
'toast.space_added_onboard': 'Space added. Outline the rooms: click grid dots and close the contour.',
|
||||
'toast.space_added': 'Space added',
|
||||
'toast.space_saved': 'Space saved',
|
||||
'toast.space_deleted': 'Space deleted',
|
||||
'toast.delete_failed': 'Delete failed: {err}',
|
||||
'toast.error': 'Error: {err}',
|
||||
'toast.file_failed': 'File "{name}" was not uploaded: {err}',
|
||||
'toast.files_attached': 'Files attached: {n}',
|
||||
|
||||
'err.unknown': 'unknown error',
|
||||
'err.code': 'code {code}',
|
||||
'err.too_large': 'file larger than {mb} MB',
|
||||
'err.bad_ext': 'unsupported type (PDF/image expected)',
|
||||
'err.unauthorized': 'administrator rights required',
|
||||
|
||||
'editor.title': 'Title',
|
||||
'editor.default_floor': 'Default space',
|
||||
'editor.icon_size': 'Icon size, % of plan width',
|
||||
'editor.show_temperature': 'Show temperature',
|
||||
'editor.live_states': 'Live states (on/off, open…)',
|
||||
'editor.show_signal': 'Show zigbee signal (LQI)',
|
||||
'editor.language': 'Interface language',
|
||||
'editor.lang_auto': 'Auto (HA profile)',
|
||||
'editor.lang_en': 'English',
|
||||
'editor.lang_ru': 'Русский',
|
||||
};
|
||||
|
||||
type Key = keyof typeof en;
|
||||
|
||||
const ru: Record<Key, string> = {
|
||||
'card.title': 'План дома',
|
||||
'count.devices': '{n} устр.',
|
||||
|
||||
'empty.no_spaces': 'Пространств пока нет.',
|
||||
'empty.add_first': 'Добавьте первое пространство и загрузите план этажа.',
|
||||
'empty.install': 'Установите интеграцию House Plan и добавьте запись в «Устройства и службы».',
|
||||
|
||||
'btn.add_space': 'Добавить пространство',
|
||||
'btn.cancel': 'Отмена',
|
||||
'btn.save': 'Сохранить',
|
||||
'btn.close': 'Закрыть',
|
||||
'btn.delete': 'Удалить',
|
||||
'btn.remove': 'Убрать',
|
||||
'btn.edit': 'Редактировать',
|
||||
'btn.open_in_ha': 'Открыть в HA',
|
||||
'btn.reset': 'Сброс',
|
||||
'btn.attach': 'Прикрепить…',
|
||||
'btn.upload': 'Загрузить…',
|
||||
'btn.replace': 'Заменить…',
|
||||
'btn.no_area': 'Без зоны',
|
||||
|
||||
'title.zoom_in': 'Приблизить',
|
||||
'title.zoom_out': 'Отдалить',
|
||||
'title.zoom_reset': 'Сбросить масштаб',
|
||||
'title.add_device': 'Добавить устройство на план',
|
||||
'title.show_all': 'Показывать все устройства зоны (без курирования)',
|
||||
'title.reset_layout': 'Сбросить позиции значков к авто-раскладке',
|
||||
'title.markup': 'Разметка комнат: сетка, линии, контуры',
|
||||
'title.configure_space': 'Настроить пространство',
|
||||
'title.add_space': 'Добавить пространство',
|
||||
'title.markup_add': 'Добавить комнату: соединяйте точки сетки линиями до замкнутого контура',
|
||||
'title.markup_erase': 'Стереть линию: клик по линии',
|
||||
'title.markup_delroom': 'Удалить комнату: клик внутри комнаты',
|
||||
'title.no_area_room': 'Декоративная комната без привязки к зоне (например, холл)',
|
||||
'title.choose_area': 'Выберите зону Home Assistant',
|
||||
'title.need_plan': 'Загрузите подложку (план этажа)',
|
||||
|
||||
'markup.add': 'Добавить',
|
||||
'markup.erase': 'Стереть',
|
||||
'markup.delete': 'Удалить',
|
||||
'markup.hint_points': 'точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой',
|
||||
'markup.hint_start': 'кликните точку сетки, чтобы начать контур',
|
||||
|
||||
'tip.room': 'комната — открыть зону',
|
||||
'tip.lqi': 'средний сигнал zigbee:',
|
||||
|
||||
'info.device_header': 'Устройство на плане',
|
||||
'info.model': 'Модель',
|
||||
'info.state': 'Состояние',
|
||||
'info.link': 'Ссылка',
|
||||
'info.manuals': 'Инструкции',
|
||||
'info.none': 'Нет дополнительной информации',
|
||||
|
||||
'marker.new_device': 'Новое устройство',
|
||||
'marker.name_label': 'Имя (отображается на плане)',
|
||||
'marker.name_ph': 'Название',
|
||||
'marker.binding_label': 'Привязка к устройству HA',
|
||||
'marker.virtual_option': 'Виртуальное устройство (без привязки)',
|
||||
'marker.search_ph': 'Поиск устройства / группы…',
|
||||
'marker.nothing_found': 'ничего не найдено',
|
||||
'marker.room_label': 'Комната',
|
||||
'marker.room_override': ' (переопределить размещение)',
|
||||
'marker.room_choose': '— выберите комнату —',
|
||||
'marker.room_auto': '— по зоне устройства (авто) —',
|
||||
'marker.icon_label': 'Иконка',
|
||||
'marker.icon_ph': 'mdi:… (пусто = авто)',
|
||||
'marker.model_label': 'Модель',
|
||||
'marker.model_ph': 'напр. Aqara T&H',
|
||||
'marker.link_label': 'Ссылка',
|
||||
'marker.desc_label': 'Описание',
|
||||
'marker.desc_ph': 'Заметки, характеристики…',
|
||||
'marker.manuals_label': 'Инструкции (PDF и т.п.)',
|
||||
'marker.sub_device': 'устройство',
|
||||
'marker.sub_z2m_group': ' · Z2M-группа',
|
||||
'marker.sub_group': 'группа',
|
||||
'marker.sub_helper': 'хелпер',
|
||||
|
||||
'space.new': 'Новое пространство',
|
||||
'space.header': 'Пространство',
|
||||
'space.title_label': 'Название',
|
||||
'space.title_ph': 'Например: Гараж',
|
||||
'space.plan_label': 'Подложка (план)',
|
||||
'space.no_plan': 'нет подложки',
|
||||
'space.plan_alt': 'план',
|
||||
|
||||
'room.new': 'Новая комната',
|
||||
'room.name_label': 'Отображаемое имя',
|
||||
'room.name_ph': 'Например: Терраса',
|
||||
'room.area_label': 'Зона Home Assistant (свободные)',
|
||||
'room.no_area_option': '— без зоны —',
|
||||
'room.default_name': 'Комната',
|
||||
|
||||
'device.unnamed': 'без имени',
|
||||
'device.light_group': 'группа света',
|
||||
'device.fallback': 'устройство',
|
||||
'device.virtual': 'виртуальное устройство',
|
||||
|
||||
'confirm.reset_layout': 'Сбросить позиции всех иконок к авто-раскладке?',
|
||||
'confirm.delete_room': 'Удалить комнату «{name}»?',
|
||||
'confirm.remove_marker': 'Убрать «{name}» с плана?',
|
||||
'confirm.delete_space': 'Удалить пространство «{title}» со всеми комнатами и разметкой?',
|
||||
|
||||
'toast.pos_save_failed': 'Не удалось сохранить позицию: {err}',
|
||||
'toast.no_entity': 'У устройства нет подходящей сущности',
|
||||
'toast.markup_needs_server': 'Разметка доступна после переноса конфига на сервер',
|
||||
'toast.conflict': 'Конфиг изменён в другом окне — данные обновлены, повторите последнее действие',
|
||||
'toast.cfg_save_failed': 'Не удалось сохранить конфиг: {err}',
|
||||
'toast.room_saved': 'Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.',
|
||||
'toast.room_saved_no_area': 'Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.',
|
||||
'toast.marker_needs_server': 'Редактирование устройств доступно после переноса конфига на сервер',
|
||||
'toast.virtual_name_required': 'Укажите имя виртуального устройства',
|
||||
'toast.marker_saved': 'Устройство сохранено',
|
||||
'toast.marker_removed': 'Устройство убрано с плана',
|
||||
'toast.integration_missing': 'Интеграция House Plan не установлена — управление недоступно',
|
||||
'toast.plan_formats': 'Поддерживаются SVG, PNG, JPG, WebP',
|
||||
'toast.plan_required': 'Загрузите подложку — план этажа обязателен',
|
||||
'toast.space_added_onboard': 'Пространство добавлено. Обведите комнаты: кликайте по точкам сетки и замкните контур.',
|
||||
'toast.space_added': 'Пространство добавлено',
|
||||
'toast.space_saved': 'Пространство сохранено',
|
||||
'toast.space_deleted': 'Пространство удалено',
|
||||
'toast.delete_failed': 'Ошибка удаления: {err}',
|
||||
'toast.error': 'Ошибка: {err}',
|
||||
'toast.file_failed': 'Файл «{name}» не загружен: {err}',
|
||||
'toast.files_attached': 'Прикреплено файлов: {n}',
|
||||
|
||||
'err.unknown': 'неизвестная ошибка',
|
||||
'err.code': 'код {code}',
|
||||
'err.too_large': 'файл больше {mb} МБ',
|
||||
'err.bad_ext': 'недопустимый тип (нужен PDF/изображение)',
|
||||
'err.unauthorized': 'нужны права администратора',
|
||||
|
||||
'editor.title': 'Заголовок',
|
||||
'editor.default_floor': 'Пространство по умолчанию',
|
||||
'editor.icon_size': 'Размер иконок, % ширины плана',
|
||||
'editor.show_temperature': 'Показывать температуру',
|
||||
'editor.live_states': 'Живые состояния (вкл/выкл, открыто…)',
|
||||
'editor.show_signal': 'Показывать сигнал zigbee (LQI)',
|
||||
'editor.language': 'Язык интерфейса',
|
||||
'editor.lang_auto': 'Авто (профиль HA)',
|
||||
'editor.lang_en': 'English',
|
||||
'editor.lang_ru': 'Русский',
|
||||
};
|
||||
|
||||
const DICTS: Record<Lang, Record<Key, string>> = { en, ru };
|
||||
const DICTS: Record<Lang, Record<string, string>> = { en, ru };
|
||||
|
||||
/** Resolve the UI language: explicit config option wins, then the HA profile. */
|
||||
export function langOf(hass: any, configLang?: string | null): Lang {
|
||||
if (configLang === 'ru' || configLang === 'en') return configLang;
|
||||
if (configLang && configLang in DICTS) return configLang as Lang;
|
||||
const l = (hass?.locale?.language || hass?.language || 'en').toLowerCase();
|
||||
return l.startsWith('ru') ? 'ru' : 'en';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"card.title": "House plan",
|
||||
"count.devices": "{n} dev.",
|
||||
"empty.no_spaces": "No spaces yet.",
|
||||
"empty.add_first": "Add the first space and upload a floor plan.",
|
||||
"empty.install": "Install the House Plan integration and add it in \"Devices & services\".",
|
||||
"btn.add_space": "Add space",
|
||||
"btn.cancel": "Cancel",
|
||||
"btn.save": "Save",
|
||||
"btn.close": "Close",
|
||||
"btn.delete": "Delete",
|
||||
"btn.remove": "Remove",
|
||||
"btn.edit": "Edit",
|
||||
"btn.open_in_ha": "Open in HA",
|
||||
"btn.reset": "Reset",
|
||||
"btn.attach": "Attach…",
|
||||
"btn.upload": "Upload…",
|
||||
"btn.replace": "Replace…",
|
||||
"btn.no_area": "No area",
|
||||
"title.zoom_in": "Zoom in",
|
||||
"title.zoom_out": "Zoom out",
|
||||
"title.zoom_reset": "Reset zoom",
|
||||
"title.add_device": "Add a device to the plan",
|
||||
"title.show_all": "Show all area devices (no curation)",
|
||||
"title.reset_layout": "Reset icon positions to auto layout",
|
||||
"title.markup": "Room markup: grid, lines, outlines",
|
||||
"title.configure_space": "Configure space",
|
||||
"title.add_space": "Add space",
|
||||
"title.markup_add": "Add a room: connect grid dots with lines until the outline closes",
|
||||
"title.markup_erase": "Erase a line: click the line",
|
||||
"title.markup_delroom": "Delete a room: click inside the room",
|
||||
"title.no_area_room": "Decorative room without an HA area (e.g. a hallway)",
|
||||
"title.choose_area": "Select a Home Assistant area",
|
||||
"title.need_plan": "Upload a floor-plan image",
|
||||
"markup.add": "Add",
|
||||
"markup.erase": "Erase",
|
||||
"markup.delete": "Delete",
|
||||
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
|
||||
"markup.hint_start": "click a grid dot to start the outline",
|
||||
"tip.room": "room — open the area",
|
||||
"tip.lqi": "average zigbee signal:",
|
||||
"info.device_header": "Device on the plan",
|
||||
"info.model": "Model",
|
||||
"info.state": "State",
|
||||
"info.link": "Link",
|
||||
"info.manuals": "Manuals",
|
||||
"info.none": "No additional information",
|
||||
"marker.new_device": "New device",
|
||||
"marker.name_label": "Name (shown on the plan)",
|
||||
"marker.name_ph": "Name",
|
||||
"marker.binding_label": "Bind to an HA device",
|
||||
"marker.virtual_option": "Virtual device (no binding)",
|
||||
"marker.search_ph": "Search device / group…",
|
||||
"marker.nothing_found": "nothing found",
|
||||
"marker.room_label": "Room",
|
||||
"marker.room_override": " (override placement)",
|
||||
"marker.room_choose": "— select a room —",
|
||||
"marker.room_auto": "— by device area (auto) —",
|
||||
"marker.icon_label": "Icon",
|
||||
"marker.icon_ph": "mdi:… (empty = auto)",
|
||||
"marker.model_label": "Model",
|
||||
"marker.model_ph": "e.g. Aqara T&H",
|
||||
"marker.link_label": "Link",
|
||||
"marker.desc_label": "Description",
|
||||
"marker.desc_ph": "Notes, specs…",
|
||||
"marker.manuals_label": "Manuals (PDF etc.)",
|
||||
"marker.sub_device": "device",
|
||||
"marker.sub_z2m_group": " · Z2M group",
|
||||
"marker.sub_group": "group",
|
||||
"marker.sub_helper": "helper",
|
||||
"space.new": "New space",
|
||||
"space.header": "Space",
|
||||
"space.title_label": "Title",
|
||||
"space.title_ph": "e.g. Garage",
|
||||
"space.plan_label": "Floor plan (background)",
|
||||
"space.no_plan": "no plan image",
|
||||
"space.plan_alt": "plan",
|
||||
"room.new": "New room",
|
||||
"room.name_label": "Display name",
|
||||
"room.name_ph": "e.g. Terrace",
|
||||
"room.area_label": "Home Assistant area (unassigned)",
|
||||
"room.no_area_option": "— no area —",
|
||||
"room.default_name": "Room",
|
||||
"device.unnamed": "unnamed",
|
||||
"device.light_group": "light group",
|
||||
"device.fallback": "device",
|
||||
"device.virtual": "virtual device",
|
||||
"confirm.reset_layout": "Reset all icon positions to the auto layout?",
|
||||
"confirm.delete_room": "Delete room \"{name}\"?",
|
||||
"confirm.remove_marker": "Remove \"{name}\" from the plan?",
|
||||
"confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?",
|
||||
"toast.pos_save_failed": "Failed to save position: {err}",
|
||||
"toast.no_entity": "The device has no suitable entity",
|
||||
"toast.markup_needs_server": "Markup is available after the config is moved to the server",
|
||||
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
|
||||
"toast.cfg_save_failed": "Failed to save config: {err}",
|
||||
"toast.room_saved": "Room saved ({n}). Devices added: {added}. Outline the next one or exit markup.",
|
||||
"toast.room_saved_no_area": "Room saved ({n}, no area). Outline the next one or exit markup.",
|
||||
"toast.marker_needs_server": "Device editing is available after the config is moved to the server",
|
||||
"toast.virtual_name_required": "Enter a name for the virtual device",
|
||||
"toast.marker_saved": "Device saved",
|
||||
"toast.marker_removed": "Device removed from the plan",
|
||||
"toast.integration_missing": "The House Plan integration is not installed — management unavailable",
|
||||
"toast.plan_formats": "Supported formats: SVG, PNG, JPG, WebP",
|
||||
"toast.plan_required": "Upload a floor plan — it is required",
|
||||
"toast.space_added_onboard": "Space added. Outline the rooms: click grid dots and close the contour.",
|
||||
"toast.space_added": "Space added",
|
||||
"toast.space_saved": "Space saved",
|
||||
"toast.space_deleted": "Space deleted",
|
||||
"toast.delete_failed": "Delete failed: {err}",
|
||||
"toast.error": "Error: {err}",
|
||||
"toast.file_failed": "File \"{name}\" was not uploaded: {err}",
|
||||
"toast.files_attached": "Files attached: {n}",
|
||||
"err.unknown": "unknown error",
|
||||
"err.code": "code {code}",
|
||||
"err.too_large": "file larger than {mb} MB",
|
||||
"err.bad_ext": "unsupported type (PDF/image expected)",
|
||||
"err.unauthorized": "administrator rights required",
|
||||
"editor.title": "Title",
|
||||
"editor.default_floor": "Default space",
|
||||
"editor.icon_size": "Icon size, % of plan width",
|
||||
"editor.show_temperature": "Show temperature",
|
||||
"editor.live_states": "Live states (on/off, open…)",
|
||||
"editor.show_signal": "Show zigbee signal (LQI)",
|
||||
"editor.language": "Interface language",
|
||||
"editor.lang_auto": "Auto (HA profile)",
|
||||
"editor.lang_en": "English",
|
||||
"editor.lang_ru": "Русский",
|
||||
"title.icon_rules": "Icon rules: which MDI icon devices get by name",
|
||||
"rules.title": "Icon rules",
|
||||
"rules.hint": "Rules are checked top-down against “device name + model” (case-insensitive regex); the first match wins. When nothing matches, the entity device class decides, then the generic chip icon.",
|
||||
"rules.pattern_ph": "regex, e.g. plug|socket",
|
||||
"rules.icon_ph": "mdi:power-socket-de",
|
||||
"rules.add": "Add rule",
|
||||
"rules.reset": "Reset to defaults",
|
||||
"rules.test_ph": "Try a device name…",
|
||||
"rules.invalid": "invalid regex",
|
||||
"rules.saved": "Icon rules saved",
|
||||
"btn.up": "Up",
|
||||
"btn.down": "Down",
|
||||
"editor.tap_action": "Tap on a device",
|
||||
"tap.info": "Info card",
|
||||
"tap.more_info": "HA more-info dialog",
|
||||
"tap.toggle": "Toggle (lights/switches)",
|
||||
"tap.auto": "As the card default",
|
||||
"marker.tap_label": "Tap action for this device",
|
||||
"tap.toggle_note": "Toggle never applies to locks and alarms; hold the icon to open the info card.",
|
||||
"import.title": "Create spaces from HA floors",
|
||||
"import.hint": "Your Home Assistant already knows these floors. Pick the ones to turn into plan spaces — you will upload a floor-plan image for each one next. Rooms are then outlined by hand on the plan.",
|
||||
"import.start": "Create {n} space(s)",
|
||||
"import.manual": "Start from scratch",
|
||||
"import.progress": "Floor {i} of {n}",
|
||||
"import.done": "Spaces created. Outline the rooms: click grid dots and close the contour.",
|
||||
"btn.skip": "Skip"
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"card.title": "План дома",
|
||||
"count.devices": "{n} устр.",
|
||||
"empty.no_spaces": "Пространств пока нет.",
|
||||
"empty.add_first": "Добавьте первое пространство и загрузите план этажа.",
|
||||
"empty.install": "Установите интеграцию House Plan и добавьте запись в «Устройства и службы».",
|
||||
"btn.add_space": "Добавить пространство",
|
||||
"btn.cancel": "Отмена",
|
||||
"btn.save": "Сохранить",
|
||||
"btn.close": "Закрыть",
|
||||
"btn.delete": "Удалить",
|
||||
"btn.remove": "Убрать",
|
||||
"btn.edit": "Редактировать",
|
||||
"btn.open_in_ha": "Открыть в HA",
|
||||
"btn.reset": "Сброс",
|
||||
"btn.attach": "Прикрепить…",
|
||||
"btn.upload": "Загрузить…",
|
||||
"btn.replace": "Заменить…",
|
||||
"btn.no_area": "Без зоны",
|
||||
"title.zoom_in": "Приблизить",
|
||||
"title.zoom_out": "Отдалить",
|
||||
"title.zoom_reset": "Сбросить масштаб",
|
||||
"title.add_device": "Добавить устройство на план",
|
||||
"title.show_all": "Показывать все устройства зоны (без курирования)",
|
||||
"title.reset_layout": "Сбросить позиции значков к авто-раскладке",
|
||||
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
"title.markup_add": "Добавить комнату: соединяйте точки сетки линиями до замкнутого контура",
|
||||
"title.markup_erase": "Стереть линию: клик по линии",
|
||||
"title.markup_delroom": "Удалить комнату: клик внутри комнаты",
|
||||
"title.no_area_room": "Декоративная комната без привязки к зоне (например, холл)",
|
||||
"title.choose_area": "Выберите зону Home Assistant",
|
||||
"title.need_plan": "Загрузите подложку (план этажа)",
|
||||
"markup.add": "Добавить",
|
||||
"markup.erase": "Стереть",
|
||||
"markup.delete": "Удалить",
|
||||
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
|
||||
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
|
||||
"tip.room": "комната — открыть зону",
|
||||
"tip.lqi": "средний сигнал zigbee:",
|
||||
"info.device_header": "Устройство на плане",
|
||||
"info.model": "Модель",
|
||||
"info.state": "Состояние",
|
||||
"info.link": "Ссылка",
|
||||
"info.manuals": "Инструкции",
|
||||
"info.none": "Нет дополнительной информации",
|
||||
"marker.new_device": "Новое устройство",
|
||||
"marker.name_label": "Имя (отображается на плане)",
|
||||
"marker.name_ph": "Название",
|
||||
"marker.binding_label": "Привязка к устройству HA",
|
||||
"marker.virtual_option": "Виртуальное устройство (без привязки)",
|
||||
"marker.search_ph": "Поиск устройства / группы…",
|
||||
"marker.nothing_found": "ничего не найдено",
|
||||
"marker.room_label": "Комната",
|
||||
"marker.room_override": " (переопределить размещение)",
|
||||
"marker.room_choose": "— выберите комнату —",
|
||||
"marker.room_auto": "— по зоне устройства (авто) —",
|
||||
"marker.icon_label": "Иконка",
|
||||
"marker.icon_ph": "mdi:… (пусто = авто)",
|
||||
"marker.model_label": "Модель",
|
||||
"marker.model_ph": "напр. Aqara T&H",
|
||||
"marker.link_label": "Ссылка",
|
||||
"marker.desc_label": "Описание",
|
||||
"marker.desc_ph": "Заметки, характеристики…",
|
||||
"marker.manuals_label": "Инструкции (PDF и т.п.)",
|
||||
"marker.sub_device": "устройство",
|
||||
"marker.sub_z2m_group": " · Z2M-группа",
|
||||
"marker.sub_group": "группа",
|
||||
"marker.sub_helper": "хелпер",
|
||||
"space.new": "Новое пространство",
|
||||
"space.header": "Пространство",
|
||||
"space.title_label": "Название",
|
||||
"space.title_ph": "Например: Гараж",
|
||||
"space.plan_label": "Подложка (план)",
|
||||
"space.no_plan": "нет подложки",
|
||||
"space.plan_alt": "план",
|
||||
"room.new": "Новая комната",
|
||||
"room.name_label": "Отображаемое имя",
|
||||
"room.name_ph": "Например: Терраса",
|
||||
"room.area_label": "Зона Home Assistant (свободные)",
|
||||
"room.no_area_option": "— без зоны —",
|
||||
"room.default_name": "Комната",
|
||||
"device.unnamed": "без имени",
|
||||
"device.light_group": "группа света",
|
||||
"device.fallback": "устройство",
|
||||
"device.virtual": "виртуальное устройство",
|
||||
"confirm.reset_layout": "Сбросить позиции всех иконок к авто-раскладке?",
|
||||
"confirm.delete_room": "Удалить комнату «{name}»?",
|
||||
"confirm.remove_marker": "Убрать «{name}» с плана?",
|
||||
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
|
||||
"toast.pos_save_failed": "Не удалось сохранить позицию: {err}",
|
||||
"toast.no_entity": "У устройства нет подходящей сущности",
|
||||
"toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер",
|
||||
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
|
||||
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
|
||||
"toast.room_saved": "Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.",
|
||||
"toast.room_saved_no_area": "Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.",
|
||||
"toast.marker_needs_server": "Редактирование устройств доступно после переноса конфига на сервер",
|
||||
"toast.virtual_name_required": "Укажите имя виртуального устройства",
|
||||
"toast.marker_saved": "Устройство сохранено",
|
||||
"toast.marker_removed": "Устройство убрано с плана",
|
||||
"toast.integration_missing": "Интеграция House Plan не установлена — управление недоступно",
|
||||
"toast.plan_formats": "Поддерживаются SVG, PNG, JPG, WebP",
|
||||
"toast.plan_required": "Загрузите подложку — план этажа обязателен",
|
||||
"toast.space_added_onboard": "Пространство добавлено. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
|
||||
"toast.space_added": "Пространство добавлено",
|
||||
"toast.space_saved": "Пространство сохранено",
|
||||
"toast.space_deleted": "Пространство удалено",
|
||||
"toast.delete_failed": "Ошибка удаления: {err}",
|
||||
"toast.error": "Ошибка: {err}",
|
||||
"toast.file_failed": "Файл «{name}» не загружен: {err}",
|
||||
"toast.files_attached": "Прикреплено файлов: {n}",
|
||||
"err.unknown": "неизвестная ошибка",
|
||||
"err.code": "код {code}",
|
||||
"err.too_large": "файл больше {mb} МБ",
|
||||
"err.bad_ext": "недопустимый тип (нужен PDF/изображение)",
|
||||
"err.unauthorized": "нужны права администратора",
|
||||
"editor.title": "Заголовок",
|
||||
"editor.default_floor": "Пространство по умолчанию",
|
||||
"editor.icon_size": "Размер иконок, % ширины плана",
|
||||
"editor.show_temperature": "Показывать температуру",
|
||||
"editor.live_states": "Живые состояния (вкл/выкл, открыто…)",
|
||||
"editor.show_signal": "Показывать сигнал zigbee (LQI)",
|
||||
"editor.language": "Язык интерфейса",
|
||||
"editor.lang_auto": "Авто (профиль HA)",
|
||||
"editor.lang_en": "English",
|
||||
"editor.lang_ru": "Русский",
|
||||
"title.icon_rules": "Правила иконок: какая MDI-иконка достаётся устройству по имени",
|
||||
"rules.title": "Правила иконок",
|
||||
"rules.hint": "Правила проверяются сверху вниз по строке «имя устройства + модель» (regex без учёта регистра); срабатывает первое совпадение. Если ничего не подошло — решает device class сущности, затем — иконка-заглушка.",
|
||||
"rules.pattern_ph": "regex, напр. розетк|plug",
|
||||
"rules.icon_ph": "mdi:power-socket-de",
|
||||
"rules.add": "Добавить правило",
|
||||
"rules.reset": "Сбросить к умолчаниям",
|
||||
"rules.test_ph": "Проверьте имя устройства…",
|
||||
"rules.invalid": "некорректный regex",
|
||||
"rules.saved": "Правила иконок сохранены",
|
||||
"btn.up": "Вверх",
|
||||
"btn.down": "Вниз",
|
||||
"editor.tap_action": "Тап по устройству",
|
||||
"tap.info": "Инфо-карточка",
|
||||
"tap.more_info": "Диалог HA (more-info)",
|
||||
"tap.toggle": "Переключить (свет/розетки)",
|
||||
"tap.auto": "Как в настройках карточки",
|
||||
"marker.tap_label": "Действие по тапу для этого устройства",
|
||||
"tap.toggle_note": "Toggle никогда не применяется к замкам и сигнализациям; долгое нажатие всегда открывает инфо-карточку.",
|
||||
"import.title": "Создать пространства из этажей HA",
|
||||
"import.hint": "Home Assistant уже знает эти этажи. Отметьте, какие превратить в пространства плана — далее для каждого попросим картинку плана. Комнаты затем обводятся вручную по плану.",
|
||||
"import.start": "Создать: {n}",
|
||||
"import.manual": "Начать с нуля",
|
||||
"import.progress": "Этаж {i} из {n}",
|
||||
"import.done": "Пространства созданы. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
|
||||
"btn.skip": "Пропустить"
|
||||
}
|
||||
@@ -112,3 +112,63 @@ export function safeUrl(url: string | null | undefined): string | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------- tap actions ----------------
|
||||
|
||||
export type TapAction = 'info' | 'more-info' | 'toggle';
|
||||
|
||||
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
|
||||
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier']);
|
||||
|
||||
/**
|
||||
* Domains that must NEVER toggle from a plan tap, even with an explicit
|
||||
* per-device override: an accidental tap unlocking a door or disarming an
|
||||
* alarm is a security incident, not a UX shortcut.
|
||||
*/
|
||||
export const TOGGLE_FORBIDDEN_DOMAINS = new Set(['lock', 'alarm_control_panel']);
|
||||
|
||||
/**
|
||||
* Resolve the effective tap action for a device icon.
|
||||
*
|
||||
* Order: per-device override → card-wide default → 'info'.
|
||||
* 'toggle' is applied conservatively: a card-wide toggle only affects
|
||||
* TOGGLE_SAFE_DOMAINS; an explicit per-device toggle affects any domain
|
||||
* except TOGGLE_FORBIDDEN_DOMAINS. Everything else falls back to 'info'.
|
||||
*/
|
||||
export function resolveTapAction(
|
||||
explicit: string | null | undefined,
|
||||
cardDefault: string | null | undefined,
|
||||
domain: string | null | undefined,
|
||||
): TapAction {
|
||||
const want = explicit || cardDefault || 'info';
|
||||
if (want === 'more-info') return 'more-info';
|
||||
if (want !== 'toggle') return 'info';
|
||||
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
|
||||
if (explicit === 'toggle') return 'toggle';
|
||||
return TOGGLE_SAFE_DOMAINS.has(domain) ? 'toggle' : 'info';
|
||||
}
|
||||
|
||||
// ---------------- floors import ----------------
|
||||
|
||||
export interface FloorInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number | null;
|
||||
}
|
||||
|
||||
/** HA floor registry → a list ordered by level (unknown levels last), then name. */
|
||||
export function floorsOf(hass: any): FloorInfo[] {
|
||||
const reg = hass?.floors;
|
||||
if (!reg || typeof reg !== 'object') return [];
|
||||
const list: FloorInfo[] = [];
|
||||
for (const f of Object.values<any>(reg)) {
|
||||
if (!f || !f.floor_id) continue;
|
||||
list.push({ id: f.floor_id, name: f.name || f.floor_id, level: f.level ?? null });
|
||||
}
|
||||
list.sort((a, b) => {
|
||||
const la = a.level ?? 1e9;
|
||||
const lb = b.level ?? 1e9;
|
||||
return la !== lb ? la - lb : a.name.localeCompare(b.name);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
+118
-39
@@ -1,53 +1,132 @@
|
||||
/**
|
||||
* Prototype rules — ported 1-to-1 from index.html/build_data.py (see DOCUMENTATION.md §4.2–4.5).
|
||||
* Device curation and icon rules.
|
||||
*
|
||||
* Icon rules are DATA, not code: the built-in defaults below can be overridden
|
||||
* per instance via `config.settings.icon_rules` (edited in the card UI).
|
||||
* A rule is `{ pattern, icon }`; patterns are case-insensitive regexes matched
|
||||
* against "<device name> <model>", first match wins. Invalid user regexes are
|
||||
* skipped silently at compile time (and flagged in the rules editor).
|
||||
*/
|
||||
|
||||
/** Integration domains whose devices are hidden (curation). */
|
||||
/** Integration domains whose devices are hidden by default (curation). */
|
||||
export const EXCLUDED_DOMAINS = new Set([
|
||||
'hacs', 'sun', 'backup', 'hassio', 'met', 'telegram_bot', 'mobile_app',
|
||||
'systemmonitor', 'better_thermostat', 'adaptive_lighting', 'yandex_pogoda',
|
||||
'upnp_serial_number',
|
||||
]);
|
||||
|
||||
const ICON_RULES: Array<[RegExp, string]> = [
|
||||
[/протечк/, 'mdi:water-alert'],
|
||||
[/клапан/, 'mdi:pipe-valve'],
|
||||
[/дым/, 'mdi:smoke-detector'],
|
||||
[/термоголов/, 'mdi:radiator'],
|
||||
[/температ/, 'mdi:thermometer'],
|
||||
[/qingping|air monitor|молекул/, 'mdi:air-filter'],
|
||||
[/штор/, 'mdi:roller-shade'],
|
||||
[/розетк|plug/, 'mdi:power-socket-de'],
|
||||
[/выключат|switch/, 'mdi:light-switch'],
|
||||
[/лампа|лампочк|bulb|gx53|светильник|rgb/, 'mdi:lightbulb'],
|
||||
[/камер|camera/, 'mdi:cctv'],
|
||||
[/замок|ttlock|lock|sn609|sn9161/, 'mdi:lock'],
|
||||
[/ворота|garage/, 'mdi:garage-variant'],
|
||||
[/калитк|door|открыт/, 'mdi:door'],
|
||||
[/счётчик|счетчик|kws|meter/, 'mdi:meter-electric'],
|
||||
[/вводный автомат|breaker|wifimcbn/, 'mdi:electric-switch'],
|
||||
[/myheat|котёл|котел|boiler|отоплен/, 'mdi:water-boiler'],
|
||||
[/холодильник|fridge/, 'mdi:fridge'],
|
||||
[/стиральн|washer/, 'mdi:washing-machine'],
|
||||
[/сушилк|dryer/, 'mdi:tumble-dryer'],
|
||||
[/пылесос|vacuum|dreame/, 'mdi:robot-vacuum'],
|
||||
[/soundbar|колонк|станц/, 'mdi:soundbar'],
|
||||
[/tv|телевизор|hyundaitv|mitv/, 'mdi:television'],
|
||||
[/keenetic|роутер|router/, 'mdi:router-wireless'],
|
||||
[/ибп|ups|kirpich/, 'mdi:battery-charging-high'],
|
||||
[/slzb|координат|zigbee/, 'mdi:zigbee'],
|
||||
];
|
||||
|
||||
/** Pick an MDI icon by device name/model. */
|
||||
export function iconFor(name?: string, model?: string): string {
|
||||
const s = ((name || '') + ' ' + (model || '')).toLowerCase();
|
||||
for (const [pat, icon] of ICON_RULES) {
|
||||
if (pat.test(s)) return icon;
|
||||
}
|
||||
return 'mdi:chip';
|
||||
export interface IconRule {
|
||||
pattern: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/** Domain priority for picking the device's “primary” entity (more-info). */
|
||||
/**
|
||||
* Built-in defaults, bilingual (EN + RU device-name vocabularies).
|
||||
* Kept as plain strings so the UI editor can display and clone them.
|
||||
*/
|
||||
export const DEFAULT_ICON_RULES: IconRule[] = [
|
||||
{ pattern: 'протечк|leak|water sensor', icon: 'mdi:water-alert' },
|
||||
{ pattern: 'клапан|valve', icon: 'mdi:pipe-valve' },
|
||||
{ pattern: 'дым|smoke', icon: 'mdi:smoke-detector' },
|
||||
{ pattern: 'термоголов|trv|radiator', icon: 'mdi:radiator' },
|
||||
{ pattern: 'температ|temperature|climate sensor', icon: 'mdi:thermometer' },
|
||||
{ pattern: 'qingping|air monitor|молекул|air quality', icon: 'mdi:air-filter' },
|
||||
{ pattern: 'штор|curtain|blind|shade', icon: 'mdi:roller-shade' },
|
||||
{ pattern: 'розетк|plug|socket|outlet', icon: 'mdi:power-socket-de' },
|
||||
{ pattern: 'выключат|switch', icon: 'mdi:light-switch' },
|
||||
{ pattern: 'лампа|лампочк|bulb|gx53|светильник|rgb|lamp|light strip', icon: 'mdi:lightbulb' },
|
||||
{ pattern: 'камер|camera', icon: 'mdi:cctv' },
|
||||
{ pattern: 'замок|ttlock|lock|sn609|sn9161', icon: 'mdi:lock' },
|
||||
{ pattern: 'ворота|garage|gate', icon: 'mdi:garage-variant' },
|
||||
{ pattern: 'калитк|door|открыт|contact', icon: 'mdi:door' },
|
||||
{ pattern: 'счётчик|счетчик|kws|meter', icon: 'mdi:meter-electric' },
|
||||
{ pattern: 'вводный автомат|breaker|wifimcbn', icon: 'mdi:electric-switch' },
|
||||
{ pattern: 'myheat|котёл|котел|boiler|отоплен|heating', icon: 'mdi:water-boiler' },
|
||||
{ pattern: 'холодильник|fridge', icon: 'mdi:fridge' },
|
||||
{ pattern: 'стиральн|washer|washing', icon: 'mdi:washing-machine' },
|
||||
{ pattern: 'сушилк|dryer', icon: 'mdi:tumble-dryer' },
|
||||
{ pattern: 'пылесос|vacuum|dreame|roborock', icon: 'mdi:robot-vacuum' },
|
||||
{ pattern: 'soundbar|колонк|станц|speaker', icon: 'mdi:soundbar' },
|
||||
{ pattern: 'tv|телевизор|hyundaitv|mitv|television', icon: 'mdi:television' },
|
||||
{ pattern: 'keenetic|роутер|router|mesh|access point', icon: 'mdi:router-wireless' },
|
||||
{ pattern: 'ибп|ups|kirpich', icon: 'mdi:battery-charging-high' },
|
||||
{ pattern: 'slzb|координат|zigbee|coordinator', icon: 'mdi:zigbee' },
|
||||
{ pattern: 'motion|движен|presence|присутств', icon: 'mdi:motion-sensor' },
|
||||
{ pattern: 'humidity|влажн', icon: 'mdi:water-percent' },
|
||||
];
|
||||
|
||||
export interface CompiledIconRule {
|
||||
re: RegExp;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/** Compile rules, silently skipping invalid regexes (user input!). */
|
||||
export function compileIconRules(rules: IconRule[]): CompiledIconRule[] {
|
||||
const res: CompiledIconRule[] = [];
|
||||
for (const r of rules) {
|
||||
if (!r || typeof r.pattern !== 'string' || !r.icon) continue;
|
||||
try {
|
||||
res.push({ re: new RegExp(r.pattern, 'i'), icon: r.icon });
|
||||
} catch {
|
||||
/* invalid user regex — skipped; the rules editor highlights it */
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Whether a single rule pattern is a valid regex (for the editor UI). */
|
||||
export function isValidPattern(pattern: string): boolean {
|
||||
try {
|
||||
new RegExp(pattern, 'i');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_COMPILED = compileIconRules(DEFAULT_ICON_RULES);
|
||||
|
||||
/** Fallback by entity device_class / domain when no name rule matched. */
|
||||
const DEVICE_CLASS_ICONS: Record<string, string> = {
|
||||
temperature: 'mdi:thermometer',
|
||||
humidity: 'mdi:water-percent',
|
||||
motion: 'mdi:motion-sensor',
|
||||
occupancy: 'mdi:motion-sensor',
|
||||
door: 'mdi:door',
|
||||
window: 'mdi:window-closed',
|
||||
garage_door: 'mdi:garage-variant',
|
||||
smoke: 'mdi:smoke-detector',
|
||||
moisture: 'mdi:water-alert',
|
||||
gas: 'mdi:gas-cylinder',
|
||||
power: 'mdi:meter-electric',
|
||||
energy: 'mdi:meter-electric',
|
||||
illuminance: 'mdi:brightness-5',
|
||||
co2: 'mdi:molecule-co2',
|
||||
pm25: 'mdi:air-filter',
|
||||
battery: 'mdi:battery',
|
||||
};
|
||||
|
||||
export const FALLBACK_ICON = 'mdi:chip';
|
||||
|
||||
/** Pick an MDI icon by device name/model using the given (or default) rules. */
|
||||
export function iconFor(name?: string, model?: string, rules?: CompiledIconRule[]): string {
|
||||
const s = ((name || '') + ' ' + (model || '')).toLowerCase();
|
||||
for (const { re, icon } of rules ?? DEFAULT_COMPILED) {
|
||||
if (re.test(s)) return icon;
|
||||
}
|
||||
return FALLBACK_ICON;
|
||||
}
|
||||
|
||||
/** Icon from entity device_class attributes (used when name rules gave the fallback). */
|
||||
export function iconFromDeviceClasses(deviceClasses: string[]): string | null {
|
||||
for (const dc of deviceClasses) {
|
||||
const icon = DEVICE_CLASS_ICONS[dc];
|
||||
if (icon) return icon;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Priority of domains for picking a device's "primary" entity (more-info). */
|
||||
export const DOMAIN_PRIORITY = [
|
||||
'light', 'switch', 'cover', 'valve', 'lock', 'climate', 'fan',
|
||||
'media_player', 'camera', 'vacuum', 'humidifier', 'water_heater',
|
||||
|
||||
+58
-4
@@ -165,8 +165,9 @@ export const cardStyles = css`
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
background: rgba(4, 18, 31, 0.8);
|
||||
color: #dff1ff;
|
||||
background: var(--card-background-color, var(--hp-bg));
|
||||
opacity: 0.92;
|
||||
color: var(--hp-txt);
|
||||
border: 1px solid var(--hp-accent);
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
@@ -334,14 +335,14 @@ export const cardStyles = css`
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
|
||||
background: rgba(4, 18, 31, 0.9);
|
||||
background: var(--card-background-color, var(--hp-bg));
|
||||
border: 1px solid var(--hp-accent);
|
||||
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
|
||||
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
|
||||
font-weight: 700;
|
||||
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
|
||||
color: #dff1ff;
|
||||
color: var(--hp-txt);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -577,6 +578,59 @@ export const cardStyles = css`
|
||||
ha-icon-picker {
|
||||
display: block;
|
||||
}
|
||||
.floorrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 4px;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.floorrow .floorlvl {
|
||||
color: var(--hp-muted);
|
||||
font-size: 11px;
|
||||
border: 1px solid var(--hp-line);
|
||||
border-radius: 5px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
.importprog {
|
||||
margin-left: auto;
|
||||
color: var(--hp-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.rhint {
|
||||
font-size: 12px;
|
||||
color: var(--hp-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.rtest {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.rtest .namein { flex: 1; }
|
||||
.rtest ha-icon { color: var(--hp-accent); }
|
||||
.rtesticon { font-size: 11px; color: var(--hp-muted); }
|
||||
.rrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.rrow .rpat { flex: 2; }
|
||||
.rrow .ricon { flex: 1.4; }
|
||||
.rrow .rpat.bad { border-color: #ff7a5c; }
|
||||
.rrow .rprev { --mdc-icon-size: 18px; color: var(--hp-txt); min-width: 18px; }
|
||||
.rrow .ract {
|
||||
--mdc-icon-size: 16px;
|
||||
color: var(--hp-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.rrow .ract:hover { color: var(--hp-txt); }
|
||||
.rrow .ract.del:hover { color: #ff7a5c; }
|
||||
|
||||
.dialogwrap {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
|
||||
+9
-1
@@ -37,12 +37,18 @@ export interface Marker {
|
||||
link?: string | null;
|
||||
description?: string | null;
|
||||
pdfs?: PdfRef[];
|
||||
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
spaces: any[];
|
||||
markers: Marker[];
|
||||
settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean };
|
||||
settings: {
|
||||
exclude_integrations?: string[];
|
||||
group_lights?: boolean;
|
||||
show_all?: boolean;
|
||||
icon_rules?: { pattern: string; icon: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DevItem {
|
||||
@@ -62,6 +68,7 @@ export interface DevItem {
|
||||
link?: string | null;
|
||||
description?: string | null;
|
||||
pdfs?: PdfRef[];
|
||||
tapAction?: string | null; // from the marker override
|
||||
}
|
||||
|
||||
export interface CardConfig {
|
||||
@@ -73,4 +80,5 @@ export interface CardConfig {
|
||||
live_states?: boolean;
|
||||
show_signal?: boolean;
|
||||
language?: string; // 'en' | 'ru' | '' (auto — HA profile)
|
||||
tap_action?: string; // 'info' (default) | 'more-info' | 'toggle'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const en = JSON.parse(readFileSync(new URL('../src/i18n/en.json', import.meta.url)));
|
||||
const ru = JSON.parse(readFileSync(new URL('../src/i18n/ru.json', import.meta.url)));
|
||||
|
||||
test('i18n: en and ru dictionaries carry the same key set', () => {
|
||||
const enKeys = Object.keys(en).sort();
|
||||
const ruKeys = Object.keys(ru).sort();
|
||||
assert.deepEqual(enKeys, ruKeys);
|
||||
});
|
||||
|
||||
test('i18n: no empty values', () => {
|
||||
for (const [lang, d] of [['en', en], ['ru', ru]]) {
|
||||
for (const [k, v] of Object.entries(d)) {
|
||||
assert.ok(typeof v === 'string' && v.length > 0, `${lang}:${k} is empty`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('i18n: placeholders match between languages', () => {
|
||||
const ph = (s) => (s.match(/\{\w+\}/g) || []).sort().join(',');
|
||||
for (const k of Object.keys(en)) {
|
||||
assert.equal(ph(en[k]), ph(ru[k]), `placeholder mismatch in ${k}`);
|
||||
}
|
||||
});
|
||||
+80
-2
@@ -2,9 +2,11 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||
fitView, declump, safeUrl,
|
||||
fitView, declump, safeUrl, resolveTapAction, floorsOf,
|
||||
} from '../test-build/logic.js';
|
||||
import { iconFor } from '../test-build/rules.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
} from '../test-build/rules.js';
|
||||
|
||||
test('lqiColor: boundaries and midpoint', () => {
|
||||
assert.equal(lqiColor(40), 'hsl(0, 85%, 55%)');
|
||||
@@ -128,3 +130,79 @@ test('safeUrl: allows http(s) and relative paths, cuts dangerous schemes', () =>
|
||||
assert.equal(safeUrl(undefined), null);
|
||||
assert.equal(safeUrl(' https://x.ru '), 'https://x.ru');
|
||||
});
|
||||
|
||||
test('icon rules: custom rules override defaults, first match wins', () => {
|
||||
const custom = compileIconRules([
|
||||
{ pattern: 'plug|socket', icon: 'mdi:custom-plug' },
|
||||
{ pattern: 'plug deluxe', icon: 'mdi:never-reached' },
|
||||
]);
|
||||
assert.equal(iconFor('Smart Plug deluxe', '', custom), 'mdi:custom-plug');
|
||||
// defaults are NOT consulted when custom rules are provided
|
||||
assert.equal(iconFor('Датчик протечки', '', custom), 'mdi:chip');
|
||||
});
|
||||
|
||||
test('icon rules: invalid regex is skipped, the rest still work', () => {
|
||||
const compiled = compileIconRules([
|
||||
{ pattern: '[unclosed', icon: 'mdi:broken' },
|
||||
{ pattern: 'camera', icon: 'mdi:cctv' },
|
||||
]);
|
||||
assert.equal(compiled.length, 1);
|
||||
assert.equal(iconFor('Backyard camera', '', compiled), 'mdi:cctv');
|
||||
});
|
||||
|
||||
test('icon rules: isValidPattern flags bad regexes', () => {
|
||||
assert.equal(isValidPattern('plug|socket'), true);
|
||||
assert.equal(isValidPattern('[unclosed'), false);
|
||||
});
|
||||
|
||||
test('icon rules: defaults are bilingual', () => {
|
||||
assert.equal(iconFor('Water leak sensor'), 'mdi:water-alert');
|
||||
assert.equal(iconFor('Датчик протечки кухня'), 'mdi:water-alert');
|
||||
assert.equal(iconFor('Umidifier presence sensor'), 'mdi:motion-sensor');
|
||||
});
|
||||
|
||||
test('icon rules: device_class fallback', () => {
|
||||
assert.equal(iconFromDeviceClasses(['temperature']), 'mdi:thermometer');
|
||||
assert.equal(iconFromDeviceClasses(['unknown', 'motion']), 'mdi:motion-sensor');
|
||||
assert.equal(iconFromDeviceClasses(['unknown']), null);
|
||||
assert.equal(iconFromDeviceClasses([]), null);
|
||||
});
|
||||
|
||||
test('tap action: defaults to info', () => {
|
||||
assert.equal(resolveTapAction(undefined, undefined, 'light'), 'info');
|
||||
assert.equal(resolveTapAction(null, 'info', 'switch'), 'info');
|
||||
assert.equal(resolveTapAction(null, 'more-info', 'sensor'), 'more-info');
|
||||
});
|
||||
|
||||
test('tap action: card-wide toggle only touches safe domains', () => {
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'light'), 'toggle');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'switch'), 'toggle');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover'), 'info'); // garage stays shut
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'valve'), 'info');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'sensor'), 'info');
|
||||
});
|
||||
|
||||
test('tap action: explicit per-device toggle works for cover but never for lock/alarm', () => {
|
||||
assert.equal(resolveTapAction('toggle', 'info', 'cover'), 'toggle'); // conscious choice
|
||||
assert.equal(resolveTapAction('toggle', 'toggle', 'lock'), 'info'); // hard security block
|
||||
assert.equal(resolveTapAction('toggle', 'toggle', 'alarm_control_panel'), 'info');
|
||||
assert.equal(resolveTapAction('toggle', 'info', undefined), 'info'); // no entity → nothing to toggle
|
||||
});
|
||||
|
||||
test('tap action: per-device override beats the card default', () => {
|
||||
assert.equal(resolveTapAction('info', 'toggle', 'light'), 'info');
|
||||
assert.equal(resolveTapAction('more-info', 'toggle', 'light'), 'more-info');
|
||||
});
|
||||
|
||||
test('floorsOf: sorts by level, tolerates missing registry and odd entries', () => {
|
||||
assert.deepEqual(floorsOf({}), []);
|
||||
assert.deepEqual(floorsOf({ floors: null }), []);
|
||||
const hass = { floors: {
|
||||
a: { floor_id: 'attic', name: 'Attic', level: 2 },
|
||||
g: { floor_id: 'ground', name: 'Ground', level: 0 },
|
||||
x: { floor_id: 'x', name: 'No level' },
|
||||
bad: null,
|
||||
}};
|
||||
const res = floorsOf(hass);
|
||||
assert.deepEqual(res.map((f) => f.id), ['ground', 'attic', 'x']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Shared test config. HA-harness tests (test_ha_*.py) 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."""
|
||||
|
||||
try:
|
||||
import homeassistant # noqa: F401
|
||||
HAS_HA = True
|
||||
except ImportError:
|
||||
HAS_HA = False
|
||||
|
||||
collect_ignore_glob = [] if HAS_HA else ["test_ha_*.py"]
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Config flow tests (run in CI with pytest-homeassistant-custom-component)."""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_custom_integrations(enable_custom_integrations):
|
||||
"""Allow loading custom_components in the test hass."""
|
||||
yield
|
||||
|
||||
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.async_block_till_done() # entry is auto-set-up after the flow
|
||||
|
||||
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,36 @@
|
||||
"""Entry setup/unload tests (CI)."""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_custom_integrations(enable_custom_integrations):
|
||||
"""Allow loading custom_components in the test hass."""
|
||||
yield
|
||||
|
||||
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,60 @@
|
||||
"""HTTP upload endpoint tests (CI)."""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_custom_integrations(enable_custom_integrations):
|
||||
"""Allow loading custom_components in the test hass."""
|
||||
yield
|
||||
|
||||
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:
|
||||
# no path segment may be exactly ".." (dots inside a name are harmless)
|
||||
path = body["url"].split("?", 1)[0]
|
||||
assert all(seg != ".." for seg in path.split("/"))
|
||||
assert path.startswith("/houseplan_files/files/")
|
||||
@@ -0,0 +1,102 @@
|
||||
"""WebSocket API tests (CI): layout ops, config rev conflict, not_ready gate."""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_custom_integrations(enable_custom_integrations):
|
||||
"""Allow loading custom_components in the test hass."""
|
||||
yield
|
||||
|
||||
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 only the WS commands, without an entry
|
||||
from custom_components.houseplan import websocket_api as hp_ws
|
||||
|
||||
hp_ws.async_register(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=")
|
||||
+10
-3
@@ -3,14 +3,21 @@
|
||||
"target": "es2021",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["es2021", "dom", "dom.iterable"],
|
||||
"lib": [
|
||||
"es2021",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"outDir": "tsout"
|
||||
"outDir": "tsout",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
+11
-2
@@ -1,5 +1,14 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": { "module": "esnext", "outDir": "test-build", "noEmit": false, "declaration": false },
|
||||
"include": ["src/logic.ts", "src/rules.ts"]
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"outDir": "test-build",
|
||||
"noEmit": false,
|
||||
"declaration": false,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"src/logic.ts",
|
||||
"src/rules.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user