Compare commits

...
6 Commits
Author SHA1 Message Date
Matysh 946e7543ad Merge dev: v1.43.3..v1.44.2 (feedback fixes, control-first card, review CR-1..CR-3) 2026-07-27 13:05:03 +03:00
Matysh 641c61dc19 test: the files-migrate test now sets the integration up like its neighbours
the new CR-2/CR-3 test sent WS commands without a config entry, so the
handlers were not registered and CI reported success=False
2026-07-27 13:02:10 +03:00
Matysh ae9168f6ec fix v1.44.2: external review CR-1..CR-3
CR-1: the lock invariant is restated precisely (never by an accidental
tap; the door card's labeled button is the ONE sanctioned surface),
unlocking now confirms, and smoke_lock_invariant exercises all five
actuation paths (icon tap, controls[], card entities, _cardToggle,
opening card).

CR-2: attachment migration is transactional — the server COPIES files,
the config is committed with its revision check, and only then the old
folder is removed via the new houseplan/files/cleanup. A rejected save
no longer leaves the stored urls pointing at an emptied folder.

CR-3: migrate returns an exact {source: written} mapping; only confirmed
copies are rewritten, destination name collisions get a unique name
instead of silently linking a pre-existing file, and a failed migration
raises a toast instead of being swallowed.

+1 unit test (119), +1 backend test, +1 smoke (51 total); docs
same-commit
2026-07-27 12:58:27 +03:00
Matysh 45c863138a docs v1.44.1: add the Telegram community chat (@ha_houseplan)
- badges + header line in README.md / README.ru.md
- 'Getting help & sharing your plan' section in both READMEs, asking for
  the version number when reporting (console banner / integration page)
- .github/ISSUE_TEMPLATE/config.yml contact links (chat + discussions)
- CONTRIBUTING 'Where to ask'; STATUS (community row) and SCOPE (field
  feedback source)
2026-07-27 12:51:48 +03:00
Matysh e04ef2f2e6 feat v1.44.0: control-first device card + light-source flag (user feedback)
- device card opens with controllable entities: toggles inline (finger
  targets), cover/lock/climate hand off to HA more-info; metadata and
  manuals moved below; config/diagnostic entities filtered; locks still
  never toggle from a card
- marker.is_light: a smart switch driving dumb fixtures glows in the
  light-sources fill (its own entity or the bound controls) — no
  light-group helper needed
- backend schema; smoke_card_controls.mjs, smoke_glow extended; docs
  same-commit
2026-07-27 12:41:26 +03:00
Matysh a841d17543 ux v1.43.3: room gear discoverability, bigger metrics, touch tooltips take two
- the room gear became a fixed-size pill button (was 0.9em/60% opacity
  inside the label — invisible in practice, field report); shown on
  unnamed rooms too, which is where you name them
- metrics line 0.62em -> 0.75em (unreadable on tablets)
- tooltips: latch on the first touch/pen pointer event instead of
  trusting (hover: none) alone; any touch drops an open tip
- smoke_feedback_v2.mjs; docs same-commit
2026-07-27 12:37:57 +03:00
30 changed files with 1010 additions and 148 deletions
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Telegram chat (@ha_houseplan)
url: https://t.me/ha_houseplan
about: Questions, setup help, ideas and screenshots — the fastest way to get an answer.
- name: 💡 GitHub discussions
url: https://github.com/Matysh/houseplan-card/discussions
about: Longer-form ideas and show-and-tell.
+7
View File
@@ -3,6 +3,13 @@
Thanks for your interest! The project is one HACS package: a storage **integration**
(`custom_components/houseplan/`, Python) and a **Lovelace card** (`src/`, TypeScript + Lit).
## Where to ask
Not sure whether something is a bug, or just want to discuss an idea before
writing code? The **[Telegram chat @ha_houseplan](https://t.me/ha_houseplan)**
is the quickest route to the author and other users. Bugs and concrete feature
requests still belong in [issues](https://github.com/Matysh/houseplan-card/issues).
## Five-minute setup
```bash
+18 -1
View File
@@ -5,6 +5,7 @@
[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers)
[![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Telegram chat](https://img.shields.io/badge/Telegram-chat-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
**Turn Home Assistant into a live, interactive map of your home.** Upload or draw
a floor plan, outline the rooms with your mouse — and every smart device appears
@@ -15,7 +16,7 @@ right on your Lovelace dashboard.
![Interactive Home Assistant floor plan: live rooms, devices, lights and climate on a real floorplan card](docs/images/demo.gif)
🇷🇺 [Документация на русском](README.ru.md)
🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
**Feature highlights**
@@ -258,6 +259,22 @@ turned the way it is mounted.
---
## Getting help & sharing your plan
- 💬 **[Telegram chat — @ha_houseplan](https://t.me/ha_houseplan)** — questions,
setup help, feature ideas, and screenshots of your plans. The fastest way to
reach the author and other users.
- 🐞 [GitHub issues](https://github.com/Matysh/houseplan-card/issues) — bug
reports and feature requests (please attach your House Plan version).
- 💡 [GitHub discussions](https://github.com/Matysh/houseplan-card/discussions) —
longer-form ideas.
When reporting a problem, the version number helps a lot: it is shown in the
browser console on load (`HOUSEPLAN-CARD vX.Y.Z`) and in **Settings → Devices &
Services → House Plan**.
---
## Frequently asked questions
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
+18 -1
View File
@@ -3,6 +3,7 @@
[![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases)
[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers)
[![Telegram chat](https://img.shields.io/badge/Telegram-чат-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
@@ -13,7 +14,7 @@
![Интерактивный план дома для Home Assistant: комнаты, устройства, свет и климат на реальном поэтажном плане](docs/images/demo.gif)
🇬🇧 [Documentation in English](README.md)
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
**Главное**
@@ -261,6 +262,22 @@ title: План дома
---
## Помощь и обмен опытом
- 💬 **[Чат в Telegram — @ha_houseplan](https://t.me/ha_houseplan)** — вопросы,
помощь с настройкой, идеи и скриншоты ваших планов. Самый быстрый способ
связаться с автором и другими пользователями.
- 🐞 [Issues на GitHub](https://github.com/Matysh/houseplan-card/issues) — баги
и запросы фич (пожалуйста, указывайте версию House Plan).
- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
развёрнутых обсуждений.
Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
быстрее.
---
## Часто задаваемые вопросы
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
+1 -1
View File
@@ -13,7 +13,7 @@ FILES_URL = "/houseplan_files/files"
CONTENT_URL = "/api/houseplan/content"
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.43.2"
VERSION = "1.44.2"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.43.2"
"version": "1.44.2"
}
@@ -205,6 +205,7 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
vol.Optional("controls"): vol.Any([str], None),
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("is_light"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
vol.Optional("ripple_color"): vol.Any(str, None),
+73 -20
View File
@@ -38,6 +38,7 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_files_migrate)
websocket_api.async_register_command(hass, ws_files_cleanup)
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
@@ -149,17 +150,24 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
)
@websocket_api.async_response
async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Move a marker's uploaded files to its new id (rebinding changes the id).
"""COPY a marker's uploaded files to its new id and report the exact mapping.
Without this the PDF urls keep pointing at the OLD id's folder, which then
looks orphaned and is one cleanup away from deletion — the exact way the
owner lost the sauna heater manuals (field incident, 2026-07-26).
Rebinding changes the marker id, so the files must follow (that is how the
owner lost a set of manuals, 2026-07-26). This used to MOVE them before the
revision-checked config save: when that save was rejected, the server kept
the old urls while the files had already left the old folder — a permanent
broken link (review CR-2, 2026-07-27).
Now it copies, never overwrites, and returns {src: dst} for every file so
the client can rewrite EXACTLY the urls that made it (review CR-3). The old
folder is removed later by houseplan/files/cleanup, once the config is
safely committed.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
return
from pathlib import Path
import shutil
from pathlib import Path
from .const import FILES_DIR
from .validation import sanitize_marker_id
@@ -167,32 +175,77 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
src_id = sanitize_marker_id(msg["from_id"])
dst_id = sanitize_marker_id(msg["to_id"])
if not src_id or not dst_id or src_id == dst_id:
connection.send_result(msg["id"], {"ok": True, "moved": 0})
connection.send_result(msg["id"], {"ok": True, "mapping": {}, "copied": 0})
return
base = Path(hass.config.path(FILES_DIR))
src = base / src_id
dst = base / dst_id
def _move() -> int:
def _copy() -> dict[str, str]:
if not src.is_dir():
return 0
return {}
dst.mkdir(parents=True, exist_ok=True)
n = 0
for f in src.iterdir():
mapping: dict[str, str] = {}
for f in sorted(src.iterdir()):
if not f.is_file():
continue
target = dst / f.name
if not target.exists():
shutil.move(str(f), str(target))
n += 1
try:
src.rmdir() # only when empty
except OSError:
pass
return n
if target.exists():
# a different file already owns this name — do NOT silently
# point the url at it; give the copy a unique name instead
stem, suffix = f.stem, f.suffix
i = 2
while (dst / f"{stem} ({i}){suffix}").exists():
i += 1
target = dst / f"{stem} ({i}){suffix}"
shutil.copy2(str(f), str(target))
mapping[f.name] = target.name
return mapping
moved = await hass.async_add_executor_job(_move)
connection.send_result(msg["id"], {"ok": True, "moved": moved})
try:
mapping = await hass.async_add_executor_job(_copy)
except OSError as err:
connection.send_error(msg["id"], "io_error", f"Could not copy marker files: {err}")
return
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/files/cleanup",
vol.Required("marker_id"): str,
}
)
@websocket_api.async_response
async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Delete a marker's file folder — called only AFTER the config is committed."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
return
import shutil
from pathlib import Path
from .const import FILES_DIR
from .validation import sanitize_marker_id
mid = sanitize_marker_id(msg["marker_id"])
if not mid:
connection.send_result(msg["id"], {"ok": True, "removed": False})
return
base = Path(hass.config.path(FILES_DIR)).resolve()
target = (base / mid).resolve()
if not str(target).startswith(str(base)) or target == base:
connection.send_result(msg["id"], {"ok": True, "removed": False})
return
def _rm() -> bool:
if not target.is_dir():
return False
shutil.rmtree(target, ignore_errors=True)
return True
removed = await hass.async_add_executor_job(_rm)
connection.send_result(msg["id"], {"ok": True, "removed": removed})
@websocket_api.websocket_command(
+44
View File
@@ -0,0 +1,44 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
// устройство с управляемой сущностью
const dev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('light.') || e.startsWith('switch.')));
out.hasDev = !!dev;
c._infoCard = dev; await c.updateComplete;
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
const body = sr().querySelector('.dialog .body');
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
|| body.querySelector('.entlist') === body.children[0];
const rows = [...sr().querySelectorAll('.entrow')];
out.rows = rows.length > 0;
// 2) у переключаемой сущности — кнопка с крупной зоной нажатия
const btn = sr().querySelector('.entbtn');
out.hasButton = !!btn;
const box = btn?.getBoundingClientRect();
out.tapTarget = box ? box.height >= 30 && box.width >= 60 : null;
// 3) кнопка реально переключает
const before = calls.length;
btn.click(); await c.updateComplete;
out.toggles = calls.length > before && calls.at(-1)[1] === 'toggle';
// 4) замок никогда не переключается из карточки
const n = calls.length;
c._cardToggle('lock.front_door');
out.lockNeverToggles = calls.length === n;
// 5) диагностические/конфиг-сущности не засоряют список
const ents = c._cardEntities(dev).map((e) => e.eid);
out.noConfigEntities = ents.every((e) => {
const cat = c.hass.entities[e]?.entity_category;
return cat !== 'config' && cat !== 'diagnostic';
});
c._infoCard = null; await c.updateComplete;
return out;
});
checkAll(res);
await finish(browser, res);
+49
View File
@@ -0,0 +1,49 @@
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// 1) кнопка настроек комнаты в редакторе плана: заметная, фиксированного размера
c._setMode('plan'); await c.updateComplete;
const btn = sr().querySelector('.rlgearbtn');
out.gearButtonShown = !!btn;
const cs = btn ? getComputedStyle(btn) : null;
out.gearReadable = cs ? parseFloat(cs.fontSize) >= 10 && cs.pointerEvents === 'auto' : null;
out.gearHasLabel = btn ? btn.textContent.trim().length > 0 : null;
const box = btn?.getBoundingClientRect();
out.gearTapTarget = box ? box.height >= 18 && box.width >= 40 : null;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
out.gearOpensDialog = c._roomDialog === true && !!c._roomEditId;
c._roomDialogCancel(); await c.updateComplete;
// 2) комната без имени тоже получает кнопку (её там и называют)
const room = c._curSpaceCfg.rooms[0];
const savedName = room.name;
room.name = '';
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.unnamedStillHasGear = sr().querySelectorAll('.rlgearbtn').length >= 1;
room.name = savedName; c._saveConfig(); c.requestUpdate(); await c.updateComplete;
// 3) метрики стали крупнее: 0.75em вместо 0.62em
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), show_names: true, label_temp: true } })) };
c._setMode('view'); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 150));
const lbl = [...sr().querySelectorAll('.roomlabel')].find((l) => l.querySelector('.rlmetrics'));
if (lbl) {
const nameSz = parseFloat(getComputedStyle(lbl.querySelector('.rlname')).fontSize);
const metaSz = parseFloat(getComputedStyle(lbl.querySelector('.rlmetrics')).fontSize);
out.metricsRatio = Math.round((metaSz / nameSz) * 100) / 100;
} else out.metricsRatio = 'no-metrics';
// 4) касание помечает сессию как тач и гасит тултип
c._tip = { x: 1, y: 1, title: 't', meta: 'm' };
c._notePointer(new PointerEvent('pointerdown', { pointerType: 'touch' }));
out.touchClearsTip = c._tip === null;
c._showTip(new MouseEvent('mousemove', { clientX: 5, clientY: 5 }), 'x', 'y');
out.noTipAfterTouch = !c._tip;
return out;
});
check('metricsRatio 0.75', res.metricsRatio, 0.75);
delete res.metricsRatio;
checkAll(res);
await finish(browser, res);
+16
View File
@@ -93,6 +93,22 @@ const res = await page.evaluate(async () => {
out.perSourceRadius = Math.abs(rOwn - c._cmToUnits(150)) < 0.5;
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId) };
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
// 6в) флаг «источник света»: умный выключатель с обычными светильниками
const swDev = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('switch.')));
if (swDev) {
const swEid = swDev.entities.find((e) => e.startsWith('switch.'));
c.hass = { ...c.hass, states: { ...c.hass.states, [swEid]: { ...c.hass.states[swEid], state: 'on' } } };
const spotsBefore = sr().querySelectorAll('.glowlayer circle').length;
c._serverCfg = { ...c._serverCfg, markers: [
...(c._serverCfg.markers || []).filter((m) => m.id !== swDev.id),
{ id: swDev.id, binding: swDev.bindingKind + ':' + swDev.bindingRef, is_light: true },
] };
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.switchGlows = sr().querySelectorAll('.glowlayer circle').length === spotsBefore + 1;
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== swDev.id) };
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.switchGlowsOffByDefault = sr().querySelectorAll('.glowlayer circle').length === spotsBefore;
} else { out.switchGlows = 'no-switch'; out.switchGlowsOffByDefault = 'no-switch'; }
// 7) радиус из настроек: 600 см против 300 см — вдвое больше
const r600 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 300 } };
+1
View File
@@ -5,6 +5,7 @@ const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
window.confirm = () => true; // review CR-1: unlocking now confirms
c.hass = { ...c.hass, callService: (d, s, data) => calls.push([d, s, data.entity_id]) };
await c.updateComplete;
// добавить дверь с замком на f1
+48
View File
@@ -0,0 +1,48 @@
// review CR-1: exercise EVERY actuation path and prove locks/alarms are safe
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const calls = [];
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push(`${d}.${s}:${data.entity_id}`); return Promise.resolve(); },
states: { ...c.hass.states,
'lock.front_door': { state: 'locked', attributes: { friendly_name: 'Front door' } },
'alarm_control_panel.home': { state: 'armed_away', attributes: {} } } };
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
const lockCalls = () => calls.filter((x) => x.includes('lock.') || x.includes('alarm_control_panel.'));
// 1) тап по значку устройства с замком
const lockDev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('lock.'))) || c._devices[0];
const fake = { ...lockDev, primary: 'lock.front_door', tapAction: 'toggle',
marker: { ...(lockDev.marker || {}), tap_action: 'toggle' } };
c._clickDevice(new MouseEvent('click'), fake);
out.iconTapSafe = lockCalls().length === 0;
// 2) controls[] с замком внутри
const withControls = { ...fake, tapAction: 'toggle',
marker: { controls: ['lock.front_door', 'alarm_control_panel.home'], tap_action: 'toggle' } };
c._clickDevice(new MouseEvent('click'), withControls);
out.controlsSafe = lockCalls().length === 0;
// 3) карточка устройства: замок отдаётся в more-info, а не тумблером
const kinds = c._cardEntities({ ...fake, entities: ['lock.front_door', 'alarm_control_panel.home'] });
out.cardNoToggleForLocks = kinds.every((k) => k.kind !== 'toggle');
c._cardToggle('lock.front_door');
c._cardToggle('alarm_control_panel.home');
out.cardToggleRefuses = lockCalls().length === 0;
// 4) кнопка в карточке двери — единственная разрешённая поверхность, и спрашивает подтверждение
let asked = null;
window.confirm = (msg) => { asked = msg; return false; };
c._lockAction('lock.front_door', 'unlock');
out.unlockAsksConfirm = asked !== null && lockCalls().length === 0;
window.confirm = () => true;
c._lockAction('lock.front_door', 'unlock');
out.unlockAfterConfirm = calls.at(-1) === 'lock.unlock:lock.front_door';
// запирание не спрашивает
asked = null;
window.confirm = (m) => { asked = m; return true; };
c._lockAction('lock.front_door', 'lock');
out.lockNoConfirm = asked === null && calls.at(-1) === 'lock.lock:lock.front_door';
return out;
});
checkAll(res);
await finish(browser, res);
+1 -1
View File
@@ -10,7 +10,7 @@ const res = await page.evaluate(async () => {
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true } })) };
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
// 1) шестерёнка на карточке комнаты в редакторе плана
const gear = sr().querySelector('.rlgear');
const gear = sr().querySelector('.rlgearbtn');
out.gearShown = !!gear;
gear.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
File diff suppressed because one or more lines are too long
+103 -30
View File
File diff suppressed because one or more lines are too long
+58
View File
@@ -1,5 +1,63 @@
# Changelog
## v1.44.2 — 2026-07-27 (external code review: CR-1…CR-3)
A second, adversarial review (of v1.44.0) produced three findings; all are
addressed.
- **The lock invariant is now precise and enforced (CR-1).** The reviewer was
right that "locks can never be actuated from the plan" was too absolute a
claim: the door card's Unlock button does call the service. That button is a
deliberate product decision, so the invariant is restated where it belongs
("never by an accidental tap; exactly one labeled surface"), unlocking now
**asks for confirmation**, and a new smoke exercises all five actuation paths
to prove icons, `controls[]` and the device card still refuse locks outright.
- **Attachment migration became transactional (CR-2).** Rebinding a marker used
to MOVE its files before the revision-checked config save — if that save was
rejected, the stored config kept the old urls while the files had already
left. Now the server **copies**, the config is committed, and only then the
old folder is removed (`houseplan/files/cleanup`).
- **Failed or partial migrations no longer rewrite urls (CR-3).** The copy
reports an exact `{source: written}` mapping; only confirmed copies are
rewritten, name collisions get a unique name instead of silently linking a
pre-existing file, and a failed migration surfaces as a toast with the links
left pointing at the still-existing originals.
## v1.44.1 — 2026-07-27
- Added the community chat everywhere users look: **https://t.me/ha_houseplan**
(badge and header line in both READMEs, a "Getting help" section, the issue
template contact links, CONTRIBUTING, STATUS and SCOPE).
## v1.44.0 — 2026-07-27 (user feedback: control first)
- **The device card is now a control surface.** It opens with the device's
controllable entities: lights, switches and fans toggle straight from the
card with finger-sized buttons, covers/locks/climate open Home Assistant's
own more-info. Model, links and PDF manuals moved below — on a wall tablet
this card is for running the home, not for reading documentation (field
report). Config and diagnostic entities are not listed; locks still never
toggle from a card tap.
- **"This device is a light source"** — a new per-device flag. A smart switch
driving ordinary (dumb) fixtures now casts a glow in the "Light sources"
fill without inventing a light-group helper: the glow follows the switch, or
the lights bound under "Controls light sources" when they are set.
## v1.43.3 — 2026-07-27 (user feedback: discoverability and touch)
- **Room settings were unfindable.** The gear added in v1.42.0 lived inside the
room label at 0.9em of its font and 60% opacity — a few pale pixels on a
normal plan. It is now a pill button "⚙ Room" of a fixed, readable size that
does not shrink with the card font, and it appears on **unnamed rooms too**
(that is where you name them). This also unblocks the font-size sliders,
which nobody could reach.
- **Metrics line enlarged** from 0.62 to 0.75 of the room name — the reporter
could scale the name but the sensor line stayed unreadable on a tablet. The
per-room and per-space multipliers still apply on top.
- **Touch tooltips, take two.** The `(hover: none)` guard was not enough: some
devices, skins, styluses and paired mice report `hover: hover`, so tips still
stuck under the finger. The card now also latches on the first touch/pen
pointer event and drops any open tooltip on touch.
## v1.43.2 — 2026-07-27 (external audit: the test layer)
- **The smoke suite can finally fail (T1).** All 48 headless-browser smokes used
+15 -1
View File
@@ -33,7 +33,7 @@ Editors are admin-only tools and must never leak interactions into View
|---|---|---|
| J1 | "Show the whole home and what's happening right now" — live spatial overview: device states, room fills (light/temp/LQI), values, multi-floor tabs | **Closed** |
| J2 | "Something is wrong — show me *where*" — leak/smoke/gas pulse, open doors/windows, unlocked locks, red dot on devices HA added silently | **Closed** |
| J3 | "Let me act on the obvious right from the plan" — tap-to-toggle for safe domains, info cards, guarded lock action (explicit button only, never a plan tap) | **Closed** |
| J3 | "Let me act on the obvious right from the plan" — tap-to-toggle for safe domains, info cards, guarded lock action | **Closed** |
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, curated auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
| J5 | "Room climate at a glance" — per-room temperature/humidity, comfort-range fills, room-card metrics | **Closed** |
| J6 | "Keep the plan true as the home evolves" — new-device flag, two editors, drag/resize, merge/split, multi-client live sync, optimistic locking | **Closed** |
@@ -52,6 +52,17 @@ Editors are admin-only tools and must never leak interactions into View
## Known gaps that fit the mission (build only on owner's request)
- Person/presence shown in rooms (classic floorplan ask; pure J1).
### The lock invariant, stated precisely (review CR-1)
No lock or alarm panel is ever actuated **by a tap on the plan**: icons, lock
badges, `marker.controls[]` and the device card all refuse (`resolveTapAction`
+ `TOGGLE_FORBIDDEN_DOMAINS`, `isControllable`, `_cardToggle`). There is exactly
**one** sanctioned actuation surface: the labeled Unlock/Lock button inside an
opened door card, which additionally confirms before unlocking. That is a
product decision (2026-07-22), not an oversight — but it means the invariant is
"never by accident", not "never at all". Any new actuation path must either
refuse locks or be added to this paragraph.
- Plan-level "security glance": one badge for "all locked / N open" (J2).
- Threshold colouring for room-card metrics (J5).
@@ -91,6 +102,9 @@ Editors are admin-only tools and must never leak interactions into View
unlocked/new device) · safe quick actions · per-room climate · Zigbee mesh
health · zero-to-plan GUI onboarding · keeping the plan true over years.
**Where users are:** Telegram chat https://t.me/ha_houseplan (support, feature
signals, screenshots) — treat it as the primary source of field feedback.
**Pains it removes:** hand-crafted SVG + YAML floorplans · entity-list
dashboards that hide *where* things happen · silent device sprawl · accidental
toggles of security devices · per-device dashboards that non-technical family
+1
View File
@@ -21,6 +21,7 @@
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.41.0** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | 111 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
| Product scope | docs/SCOPE.md (2026-07-22) is the feature guard rail — check before accepting any feature |
## Current feature surface (since the 2026-07-17 snapshot)
+30
View File
@@ -20,6 +20,36 @@
bundle. Sanity ritual: break one invariant on purpose (e.g. remove the
kiosk editor guard) and confirm the matching smoke goes red [auto: CI job "smoke"]
- [ ] Room gear discoverability (v1.43.3, user feedback): in the Plan editor
every room card carries a pill button "⚙ Room" of a FIXED readable size
(independent of the card font) — including rooms without a name; it opens
Room settings [auto: smoke_feedback_v2]
- [ ] Metrics readability (v1.43.3): the metrics line is 0.75 of the room name
(was 0.62 — unreadable on tablets); per-room sliders still apply on top [auto: smoke_feedback_v2]
- [ ] Touch tooltips, take two (v1.43.3): a hover tooltip never appears after
ANY touch/pen pointer event, even if the browser claims `hover: hover`
(stylus, paired mouse, vendor skins) [auto: smoke_feedback_v2]
- [ ] Light-source flag (v1.44.0, user feedback): a smart SWITCH driving dumb
fixtures glows in the "Light sources" fill once "This device is a light
source" is ticked (its own entity or the lights bound under "Controls");
unticked devices without a light entity never glow [auto: smoke_glow]
- [ ] Device card controls (v1.44.0): the device card opens with its
controllable entities FIRST — toggles right there (≥30 px tap targets),
cover/lock/climate open HA more-info; model, links and manuals moved
below; config/diagnostic entities are not listed; locks never toggle from
the card [auto: smoke_card_controls]
- [ ] Lock invariant, all paths (v1.44.2, review CR-1): icon tap, controls[],
device card and _cardToggle refuse locks/alarm panels entirely; the door
card's Unlock asks for confirmation, Lock does not [auto: smoke_lock_invariant]
- [ ] Attachment migration is transactional (v1.44.2, review CR-2/CR-3):
rebinding COPIES files, saves the config, and only then deletes the old
folder; a rejected save leaves the old files and urls intact; a name
collision in the destination gets a unique name (the pre-existing file is
never silently linked); urls are rewritten only for confirmed copies
[auto: unit logic.test + tests_backend]
## Environments matrix
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.43.2",
"version": "1.44.2",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+151 -26
View File
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.43.2';
const CARD_VERSION = '1.44.2';
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';
@@ -233,6 +233,7 @@ class HouseplanCard extends LitElement {
controls: string[]; // entities this icon toggles as a group
controlsFilter: string;
glowRadius: string; // per-device glow radius in display units; '' = global default
isLight: boolean; // force this marker to glow (dumb fixtures behind a switch)
model: string;
link: string;
description: string;
@@ -1350,17 +1351,35 @@ class HouseplanCard extends LitElement {
}, 3500);
}
/** True on touch-first devices (tablets/phones): no real hover there. */
private static readonly _noHover =
/**
* Touch-first surface: no hover tooltips.
*
* The media query alone was not enough (field report, 2026-07-27: tooltips
* still stuck on a OnePlus). Some devices/skins report `hover: hover`, and a
* stylus or a paired mouse flips it too. So this also latches on the FIRST
* touch pointer event and never unlatches for that session — a device that
* has been touched once is a touch device.
*/
private static _touchSeen = false;
private static readonly _noHoverMq =
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(hover: none)').matches;
private get _noHover(): boolean {
return HouseplanCard._noHoverMq || HouseplanCard._touchSeen;
}
/** Any touch anywhere marks the session as touch-first and kills open tips. */
private _notePointer(ev: PointerEvent): void {
if (ev.pointerType === 'touch' || ev.pointerType === 'pen') {
HouseplanCard._touchSeen = true;
if (this._tip) this._tip = null;
}
}
private _showTip(ev: MouseEvent, title: string, meta: string, lqi?: number | null, temp?: number | null): void {
// Field feedback: on tablets every tap synthesized a mousemove and popped
// the hover tooltip over the finger. Touch devices get NO hover tooltips —
// the same data lives in room cards and the long-press device card.
if (HouseplanCard._noHover) return;
if (this._noHover) return;
if (this._drag) return;
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp };
}
@@ -2411,6 +2430,7 @@ class HouseplanCard extends LitElement {
defaultTap: d.primary?.split('.')[0] === 'light' ? 'toggle' : 'info',
controls: [...(d.marker?.controls || [])],
controlsFilter: '',
isLight: d.marker?.is_light === true,
glowRadius: Number(d.marker?.glow_radius_cm) > 0
? String(this._imperial
? Math.round((Number(d.marker!.glow_radius_cm) / 30.48) * 10) / 10
@@ -2430,7 +2450,8 @@ class HouseplanCard extends LitElement {
name: '', binding: 'virtual', bindingMode: 'virtual', bindingOpen: false,
showEntities: false, bindingFilter: '', icon: '', autoIcon: '',
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', glowRadius: '', model: '',
tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
glowRadius: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false,
};
}
@@ -2624,6 +2645,7 @@ class HouseplanCard extends LitElement {
tap_action: dlg.tapAction || null,
controls: dlg.controls.length ? dlg.controls : null,
// pdfs may be rewritten below when rebinding changes the marker id
is_light: dlg.isLight ? true : null,
glow_radius_cm: (() => {
const v = parseFloat(dlg.glowRadius);
if (!Number.isFinite(v) || v <= 0) return null;
@@ -2645,14 +2667,23 @@ class HouseplanCard extends LitElement {
const prevRoomId = prevDev?.marker?.room_id ?? null;
const roomChanged = !!dlg.room && prevDev != null
&& (prevDev.space !== space || prevDev.area !== area || prevRoomId !== roomId);
// rebinding changed the id → move the uploaded files along (server-side)
// and rewrite the attached urls; otherwise the old-id folder goes orphan
// (that is how the sauna manuals were lost — incident 2026-07-26)
// Rebinding changes the marker id, so the uploaded files must follow.
// Order matters (review CR-2): COPY first, save the config, and only then
// delete the old folder. If the save is rejected, the old urls in the
// stored config still resolve — the files never left. A failed copy
// leaves the urls untouched and tells the user (review CR-3).
let cleanupOldFiles = false;
if (oldId && oldId !== id && marker.pdfs?.length) {
await this.hass
.callWS({ type: 'houseplan/files/migrate', from_id: oldId, to_id: id })
.catch(() => undefined);
marker.pdfs = migratePdfUrls(marker.pdfs, oldId, id);
try {
const res: any = await this.hass.callWS({
type: 'houseplan/files/migrate', from_id: oldId, to_id: id,
});
const mapping = res?.mapping || {};
marker.pdfs = migratePdfUrls(marker.pdfs, oldId, id, mapping);
cleanupOldFiles = Object.keys(mapping).length > 0;
} catch (e: any) {
this._showToast(this._t('toast.files_migrate_failed', { err: this._errText(e) }));
}
}
// remove the previous marker (by the old id and by the new id)
cfg.markers = cfg.markers.filter((m) => m.id !== id && m.id !== oldId);
@@ -2699,6 +2730,12 @@ class HouseplanCard extends LitElement {
delete this._layout[oldId];
await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: oldId }).catch(() => undefined);
}
// the config is committed — now it is safe to drop the old folder
if (cleanupOldFiles && oldId) {
await this.hass
.callWS({ type: 'houseplan/files/cleanup', marker_id: oldId })
.catch(() => undefined); // leftovers are harmless; broken links are not
}
this._markerDialog = null;
this._regSignature = '';
this._maybeRebuildDevices();
@@ -3115,9 +3152,15 @@ class HouseplanCard extends LitElement {
const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string[] | null; r: number }[] = [];
for (const d of this._devices) {
if (d.space !== space.id) continue;
const lightEid = d.entities.find(
(e) => e.startsWith('light.') && this.hass.states[e]?.state === 'on',
);
// A light source is normally a device with a lit light.* entity. With the
// "is a light source" flag (field request: a smart SWITCH driving dumb
// fixtures) any lit entity counts — the switch itself, or the lights it
// controls when they are bound.
const forced = d.marker?.is_light === true;
const pool = forced
? [...(d.marker?.controls || []), ...d.entities]
: d.entities.filter((e) => e.startsWith('light.'));
const lightEid = pool.find((e) => this.hass.states[e]?.state === 'on');
if (!lightEid) continue;
const glow = glowColorOf(this.hass.states[lightEid], colors.glow_light.c);
if (!glow) continue;
@@ -3475,7 +3518,7 @@ class HouseplanCard extends LitElement {
style="height:${this._kiosk ? '100dvh' : 'calc(100dvh - 118px)'}"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@pointerdown=${(e: PointerEvent) => this._stagePointerDown(e)}
@pointerdown=${(e: PointerEvent) => { this._notePointer(e); this._stagePointerDown(e); }}
@pointermove=${(e: PointerEvent) => this._stagePointerMove(e)}
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
@pointercancel=${(e: PointerEvent) => this._stagePointerUp(e)}>
@@ -3920,7 +3963,9 @@ class HouseplanCard extends LitElement {
private _renderRoomLabel(
r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number }, disp: SpaceDisplay,
): TemplateResult | typeof nothing {
if (!r.name) return nothing;
// audit/feedback: rooms without a name still need their gear in the Plan
// editor — that is where you name them (field report, 2026-07-27)
if (!r.name && !this._markup) return nothing;
const p = this._labelPos(r, space.id);
const left = ((p.x - view.x) / view.w) * 100;
const top = ((p.y - view.y) / view.h) * 100;
@@ -3959,12 +4004,14 @@ class HouseplanCard extends LitElement {
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
@pointerup=${() => this._labelUp(r)}
@pointercancel=${() => this._labelUp(r)}
><span class="rlname">${this._markup && r.id
? html`<ha-icon class="rlgear" icon="mdi:cog-outline"
title=${this._t('room.settings_title')}
>${this._markup && r.id
? html`<button class="rlgearbtn" title=${this._t('room.settings_title')}
@pointerdown=${(e: Event) => e.stopPropagation()}
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}></ha-icon>`
: nothing}${r.name}${!this._markup && r.area
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}>
<ha-icon icon="mdi:cog-outline"></ha-icon>
<span class="rlgeartext">${this._t('room.settings_short')}</span>
</button>`
: nothing}<span class="rlname">${r.name || (this._markup ? this._t('room.unnamed') : '')}${!this._markup && r.area
? html`<ha-icon class="rlgo" icon="mdi:open-in-new"
title=${this._t('room.open_area')}
@click=${(e: Event) => { e.stopPropagation(); this._clickRoom(r); }}
@@ -4226,6 +4273,16 @@ class HouseplanCard extends LitElement {
* clearly labeled action button — same interaction contract as HA's more-info.
*/
private _lockAction(entityId: string, action: 'lock' | 'unlock'): void {
// THE ONLY sanctioned lock actuation surface (review CR-1, 2026-07-27).
// The invariant is "no lock or alarm panel is ever actuated by a TAP on the
// plan" — icons, badges, controls[] and the device card all refuse. This
// button is a deliberate, labeled control inside an opened card, the same
// contract as Home Assistant's own more-info dialog. Unlocking additionally
// asks for confirmation; locking does not (locking is never destructive).
if (action === 'unlock') {
const name = this.hass?.states?.[entityId]?.attributes?.friendly_name || entityId;
if (!confirm(this._t('confirm.unlock', { name }))) return;
}
this.hass?.callService?.('lock', action, { entity_id: entityId });
}
@@ -4463,6 +4520,39 @@ class HouseplanCard extends LitElement {
</div>`;
}
/** Entities of a device worth CONTROLLING or reading, in a sensible order. */
private _cardEntities(d: DevItem): { eid: string; kind: 'toggle' | 'value' | 'open' }[] {
const h = this.hass;
const out: { eid: string; kind: 'toggle' | 'value' | 'open' }[] = [];
const seen = new Set<string>();
const push = (eid: string) => {
if (!eid || seen.has(eid) || !h.states[eid]) return;
const reg = h.entities[eid];
if (reg?.entity_category === 'config' || reg?.entity_category === 'diagnostic') return;
seen.add(eid);
const dom = eid.split('.')[0];
if (['light', 'switch', 'fan', 'humidifier', 'siren', 'input_boolean'].includes(dom))
out.push({ eid, kind: 'toggle' });
else if (['cover', 'valve', 'lock', 'climate', 'media_player', 'vacuum', 'water_heater'].includes(dom))
out.push({ eid, kind: 'open' }); // needs the full more-info UI
else if (['sensor', 'binary_sensor', 'number', 'select'].includes(dom))
out.push({ eid, kind: 'value' });
};
for (const e of d.marker?.controls || []) push(e);
if (d.primary) push(d.primary);
for (const e of d.entities) push(e);
return out.slice(0, 12);
}
/** Toggle straight from the device card (safe domains only). */
private _cardToggle(eid: string): void {
const dom = eid.split('.')[0];
if (dom === 'lock' || dom === 'alarm_control_panel') return; // never from a card tap
this.hass
.callService('homeassistant', 'toggle', { entity_id: eid })
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
}
private _renderInfoCard(): TemplateResult {
const d = this._infoCard!;
const st = d.primary ? this.hass.states[d.primary] : undefined;
@@ -4472,8 +4562,38 @@ class HouseplanCard extends LitElement {
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="${d.icon}"></ha-icon>${d.name}</div>
<div class="body">
${(() => {
// Field feedback: on a wall tablet this card is for CONTROLLING the
// home; model/links/manuals are reference material and belong below.
const ents = this._cardEntities(d);
if (!ents.length) return nothing;
return html`<div class="entlist">
${ents.map(({ eid, kind }) => {
const est = this.hass.states[eid];
const name = this.hass.entities[eid]?.name
|| est?.attributes?.friendly_name || eid;
const val = est ? this.hass.formatEntityState?.(est) ?? est.state : '';
const on = est?.state === 'on' || ['open', 'unlocked', 'playing', 'cleaning'].includes(est?.state);
return html`<div class="entrow ${on ? 'on' : ''}">
<ha-icon icon=${stateIcon(
iconFor(name, '', this._iconRules), eid.split('.')[0],
est?.attributes?.device_class, est?.state, false,
)}></ha-icon>
<span class="en">${name}</span>
${kind === 'toggle'
? html`<button class="entbtn ${on ? 'on' : ''}"
@click=${() => this._cardToggle(eid)}>${val}</button>`
: kind === 'open'
? html`<button class="entbtn"
@click=${() => { this._infoCard = null; this._openMoreInfo(eid); }}>${val}</button>`
: html`<span class="ev">${val}</span>`}
</div>`;
})}
</div>`;
})()}
${d.model ? html`<div class="inforow"><span class="k">${this._t('info.model')}</span><span>${d.model}</span></div>` : nothing}
${stateTxt ? html`<div class="inforow"><span class="k">${this._t('info.state')}</span><span>${stateTxt}</span></div>` : nothing}
${stateTxt && !this._cardEntities(d).length
? html`<div class="inforow"><span class="k">${this._t('info.state')}</span><span>${stateTxt}</span></div>` : nothing}
${safeUrl(d.link)
? html`<div class="inforow"><span class="k">${this._t('info.link')}</span>
<a href="${safeUrl(d.link)}" target="_blank" rel="noreferrer noopener">${d.link}</a></div>`
@@ -4641,6 +4761,11 @@ class HouseplanCard extends LitElement {
</div>`
: nothing}
<label class="srcrow" title=${this._t('marker.is_light_tip')}>
<input type="checkbox" .checked=${d.isLight}
@change=${(e: Event) => (this._markerDialog = { ...d, isLight: (e.target as HTMLInputElement).checked })} />
<span>${this._t('marker.is_light')}</span>
</label>
<label>${this._t('marker.glow_radius_label')}</label>
<div class="colorrow">
<input class="tempin" type="number" min="0.5" step="0.5"
+7 -1
View File
@@ -323,5 +323,11 @@
"room.name_scale": "Room name size",
"room.label_scale": "Metrics size",
"preview.room_name": "Living room",
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}"
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}",
"room.settings_short": "Room",
"room.unnamed": "Unnamed room",
"marker.is_light": "This device is a light source",
"marker.is_light_tip": "Makes the icon glow in the “Light sources” fill even without a light entity — for a smart switch driving ordinary fixtures. The glow follows the switch (or the lights bound above).",
"confirm.unlock": "Unlock “{name}”?",
"toast.files_migrate_failed": "Attachments could not be moved to the new binding, links keep pointing at the old files: {err}"
}
+7 -1
View File
@@ -323,5 +323,11 @@
"room.name_scale": "Размер названия",
"room.label_scale": "Размер подписей",
"preview.room_name": "Гостиная",
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}"
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
"room.settings_short": "Комната",
"room.unnamed": "Комната без имени",
"marker.is_light": "Это устройство — источник света",
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
"confirm.unlock": "Открыть замок «{name}»?",
"toast.files_migrate_failed": "Не удалось перенести вложения к новой привязке, ссылки остались на старые файлы: {err}"
}
+15 -2
View File
@@ -1070,12 +1070,25 @@ export function roomFillModeOf(
* server moves /files/<oldId>/ to /files/<newId>/, the urls must follow.
*/
export function migratePdfUrls<T extends { url: string }>(
pdfs: T[], oldId: string, newId: string,
pdfs: T[], oldId: string, newId: string, mapping?: Record<string, string>,
): T[] {
if (!oldId || !newId || oldId === newId) return pdfs;
const from = '/files/' + oldId + '/';
const to = '/files/' + newId + '/';
return pdfs.map((p) => (p.url.includes(from) ? { ...p, url: p.url.split(from).join(to) } : p));
return pdfs.map((p) => {
if (!p.url.includes(from)) return p;
const tail = p.url.split(from)[1] || '';
const [name, query] = [tail.split('?')[0], tail.includes('?') ? '?' + tail.split('?')[1] : ''];
if (mapping) {
// review CR-3: rewrite ONLY files the server confirmed it copied, and use
// the name it actually wrote (collisions get a unique name). A url that
// was not copied keeps pointing at the still-existing old folder.
const dst = mapping[decodeURIComponent(name)] ?? mapping[name];
if (!dst) return p;
return { ...p, url: p.url.split(from + name)[0] + to + encodeURIComponent(dst) + query };
}
return { ...p, url: p.url.split(from).join(to) };
});
}
// ---------------- kiosk gestures ----------------
+58 -1
View File
@@ -383,6 +383,27 @@ export const cardStyles = css`
gap: 0.25em;
font-size: calc(1em * var(--rl-name, 1));
}
.rlgearbtn {
display: inline-flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
padding: 3px 8px;
border: 0;
border-radius: 999px;
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
font: inherit;
font-size: 11px;
font-weight: 600;
line-height: 1;
cursor: pointer;
pointer-events: auto;
opacity: 0.92;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.rlgearbtn:hover { opacity: 1; }
.rlgearbtn ha-icon { --mdc-icon-size: 14px; display: inline-flex; }
.rlgear {
--mdc-icon-size: 0.9em;
display: inline-flex;
@@ -406,7 +427,7 @@ export const cardStyles = css`
display: flex;
align-items: center;
gap: 0.55em;
font-size: calc(0.62em * var(--rl-meta, 1));
font-size: calc(0.75em * var(--rl-meta, 1)); /* feedback: 0.62 was unreadable on a tablet */
font-weight: 600;
letter-spacing: 0.02em;
opacity: 0.9;
@@ -1194,6 +1215,42 @@ export const cardStyles = css`
.pdftag .x:hover {
color: #ff7a5c;
}
.entlist {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.entrow {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 8px;
background: var(--secondary-background-color, rgba(128, 128, 128, 0.12));
}
.entrow ha-icon { --mdc-icon-size: 20px; color: var(--hp-muted); }
.entrow.on ha-icon { color: var(--hp-accent); }
.entrow .en { flex: 1; font-size: 13px; }
.entrow .ev { font-size: 13px; color: var(--hp-muted); }
.entbtn {
min-width: 74px;
min-height: 32px;
padding: 4px 12px;
border: 1px solid var(--hp-muted);
border-radius: 999px;
background: transparent;
color: var(--hp-txt);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.entbtn.on {
background: var(--hp-accent);
border-color: var(--hp-accent);
color: var(--text-primary-color, #fff);
font-weight: 600;
}
.inforow {
display: flex;
gap: 10px;
+6
View File
@@ -61,6 +61,12 @@ export interface Marker {
controls?: string[] | null;
/** Per-source glow radius in cm (glow fill); null = the global default. */
glow_radius_cm?: number | null;
/**
* Treat this marker as a light source in the glow fill even when it has no
* light.* entity (a smart switch driving dumb fixtures — field request).
* null/undefined = auto: any light.* entity of the device.
*/
is_light?: boolean | null;
}
/** A door or window: plan geometry (normalized coords), optionally live via entities. */
+14
View File
@@ -792,6 +792,20 @@ test('clampScale', () => {
assert.equal(clampScale(undefined, 1.5), 1.5);
});
test('migratePdfUrls: only confirmed copies are rewritten (review CR-3)', () => {
const pdfs = [
{ name: 'a.pdf', url: '/houseplan_files/files/v_old1/a.pdf?v=1' },
{ name: 'b.pdf', url: '/houseplan_files/files/v_old1/b.pdf?v=2' },
];
// сервер скопировал только a.pdf, причём переименовал из-за коллизии
const out = migratePdfUrls(pdfs, 'v_old1', 'dev99', { 'a.pdf': 'a (2).pdf' });
assert.equal(out[0].url, '/houseplan_files/files/dev99/a%20(2).pdf?v=1');
assert.equal(out[1].url, pdfs[1].url, 'нескопированный файл ссылается на старую папку');
// пустой маппинг = ничего не переносим
assert.deepEqual(migratePdfUrls(pdfs, 'v_old1', 'dev99', {}).map((p) => p.url),
pdfs.map((p) => p.url));
});
test('migratePdfUrls: rebinding rewrites file urls', () => {
const pdfs = [
{ name: 'a.pdf', url: '/houseplan_files/files/v_old1/a.pdf?v=1' },
+52
View File
@@ -125,3 +125,55 @@ async def test_admin_check_fails_closed(hass, hass_ws_client):
user = _Admin()
assert wsapi._check_write(hass, _AdminConn()) is True
async def test_files_migrate_copies_and_reports_mapping(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""review CR-2/CR-3: migrate COPIES, never overwrites, and reports the mapping."""
import os
from custom_components.houseplan.const import FILES_DIR
await _setup(hass)
client = await hass_ws_client(hass)
base = hass.config.path(FILES_DIR)
src = os.path.join(base, "old1")
dst = os.path.join(base, "new1")
def _prepare() -> None:
os.makedirs(src, exist_ok=True)
os.makedirs(dst, exist_ok=True)
with open(os.path.join(src, "m.pdf"), "wb") as fh:
fh.write(b"SOURCE")
# a DIFFERENT file already owns the name in the destination
with open(os.path.join(dst, "m.pdf"), "wb") as fh:
fh.write(b"OTHER")
await hass.async_add_executor_job(_prepare)
await client.send_json_auto_id(
{"type": "houseplan/files/migrate", "from_id": "old1", "to_id": "new1"}
)
resp = await client.receive_json()
assert resp["success"], resp
mapping = resp["result"]["mapping"]
assert mapping["m.pdf"] != "m.pdf" # renamed instead of overwriting
def _read_all() -> tuple[bool, bytes, bytes]:
with open(os.path.join(dst, "m.pdf"), "rb") as fh:
other = fh.read()
with open(os.path.join(dst, mapping["m.pdf"]), "rb") as fh:
copied = fh.read()
return os.path.isfile(os.path.join(src, "m.pdf")), other, copied
src_kept, other, copied = await hass.async_add_executor_job(_read_all)
assert src_kept, "migrate must COPY, not move (review CR-2)"
assert other == b"OTHER" and copied == b"SOURCE"
# cleanup runs only after the config is safely committed
await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "old1"})
resp2 = await client.receive_json()
assert resp2["success"] and resp2["result"]["removed"] is True
assert not await hass.async_add_executor_job(lambda: os.path.isdir(src))