mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
946e7543ad | ||
|
|
641c61dc19 | ||
|
|
ae9168f6ec | ||
|
|
45c863138a | ||
|
|
e04ef2f2e6 | ||
|
|
a841d17543 |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](LICENSE)
|
||||
[](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.
|
||||
|
||||

|
||||
|
||||
🇷🇺 [Документация на русском](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
@@ -3,6 +3,7 @@
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
|
||||
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
|
||||
@@ -13,7 +14,7 @@
|
||||
|
||||

|
||||
|
||||
🇬🇧 [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?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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
Vendored
+103
-30
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user