v1.59.0-beta.2: live text labels, text block frame, warm-memo owners
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 2m29s
Validate / backend (push) Failing after 8m30s
Validate / smoke (push) Failing after 13m24s

This commit is contained in:
Matysh
2026-08-04 23:44:59 +03:00
parent 1397a71f84
commit 0ee80a6a52
25 changed files with 3103 additions and 1064 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.59.0-beta.1"
VERSION = "1.59.0-beta.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.59.0-beta.1"
"version": "1.59.0-beta.2"
}
+35 -2
View File
@@ -220,6 +220,22 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
# Live text on a decor label (docs/LIVE-TEXT.md). An entity id is
# `<domain>.<object_id>`; HA itself allows only lowercase letters, digits and
# underscores in both halves. The bound is a sanity limit, not a policy.
MAX_ENTITY_ID = 255
_ENTITY_ID = vol.All(str, vol.Length(min=3, max=MAX_ENTITY_ID),
vol.Match(r"^[a-z0-9_]+\.[a-z0-9_]+$"))
# A caption is a caption: the template is bounded, the attribute name is a flat
# name (no dots needed), and a unit is a couple of characters, not a sentence.
MAX_DECOR_TEXT = 200
MAX_DECOR_ATTR = 64
MAX_DECOR_UNIT = 16
# The text block is scaled by dragging its corners; the range is what a human
# could mean on a 1000-unit canvas, the rest is garbage insurance.
DECOR_TEXT_SCALE_MIN = 0.15
DECOR_TEXT_SCALE_MAX = 20.0
_DECOR_COMMON = {
vol.Required("id"): str,
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
@@ -240,8 +256,25 @@ DECOR_SCHEMA = vol.Any(
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("size"): vol.In(["s", "m", "l"])},
# the template: newlines are the user's own line breaks and are
# kept verbatim (docs/LIVE-TEXT.md); the label never wraps itself
vol.Required("text"): vol.All(str, vol.Length(min=1, max=MAX_DECOR_TEXT)),
# legacy font size ('s'|'m'|'l'). The dialog no longer offers it
# — the block is scaled by its corner handles — but a plan
# written before that keeps it, and it is read as the scale it
# used to render at. Kept in the schema so it stays BOUNDED.
vol.Optional("size"): vol.In(["s", "m", "l"]),
# size and rotation of the block (docs/LIVE-TEXT.md §block).
# Both optional: a shape without them is the old one exactly.
vol.Optional("scale"): vol.All(
_finite, vol.Range(min=DECOR_TEXT_SCALE_MIN, max=DECOR_TEXT_SCALE_MAX)),
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
# live text: the label may show one entity's value. All three
# optional, so every existing plan validates unchanged and no
# migration runs.
vol.Optional("entity"): vol.Any(None, _ENTITY_ID),
vol.Optional("attr"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_ATTR))),
vol.Optional("unit"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_UNIT)))},
extra=vol.ALLOW_EXTRA),
)
+8 -3
View File
@@ -86,8 +86,10 @@ const res = await page.evaluate(async () => {
c._decorTool = 'select';
await c.updateComplete;
out.stillThreeShapes = c._decorList.length === 3;
// 4) надпись через диалог
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', size: 'l', color: '#0000ff' };
// 4) надпись через диалог. Поля `size` в диалоге больше нет — размер задаётся
// углами блока (см. smoke_decor_text); сохранённый старый `size` по-прежнему
// читается как начальный масштаб.
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', color: '#0000ff' };
c._decorSaveText(); await c.updateComplete;
out.textSaved = c._decorList.some((x) => x.kind === 'text' && x.text === 'Сауна');
out.textRendered = [...sr().querySelectorAll('.decorlayer text')].some((t) => t.textContent.includes('Сауна'));
@@ -151,7 +153,10 @@ const res = await page.evaluate(async () => {
out[key] = !!c._decorDraft && c._decorDraft.kind === tool && c._decorSel === null;
c._decorDraft = null;
}
// — «текст» по фигуре открывает диалог новой надписи, а не выделяет
// — «текст» по ЛИНИИ открывает диалог новой надписи, а не выделяет её.
// Инертность нетекстовых фигур под рисующими инструментами остаётся в силе;
// единственное исключение — надпись под инструментом «текст», она открывает
// СВОЮ форму (владелец 2026-08-04, пинится в smoke_decor_text).
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null; await c.updateComplete;
press(probe(), g * 12, g * 30, 43);
await c.updateComplete;
+197
View File
@@ -0,0 +1,197 @@
// Текстовый блок декора (правки владельца 2026-08-04):
// 1) выбора размера шрифта БОЛЬШЕ НЕТ — размер задаётся углами блока, а
// сохранённый старый `size` читается как начальный масштаб;
// 2) многострочный текст: перевод строки сохраняется и рисуется, блок
// выравнивается по центру;
// 3) у выделенного блока есть угловые ручки (масштаб) и ручка поворота
// (шаг 5°, Shift — мимо шага);
// 4) при инструменте «текст» клик по УЖЕ РАЗМЕЩЁННОЙ надписи открывает её
// форму, а не создаёт новую; по пустому месту и по НЕтекстовой фигуре —
// создаёт новую (нетекстовые фигуры остаются инертными, см. smoke_decor).
// ПАДАЕТ на сборке до этих правок: в диалоге ещё радиокнопки размера, перевод
// строки схлопывается в одну строку, ручек нет, а «текст» по надписи создаёт
// вторую надпись поверх первой.
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
// смок должен показать список провалов, а не упасть с исключением
const el = (id) => sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
const attr = (id, name) => { const e = el(id); return e ? e.getAttribute(name) : null; };
const tspans = (id) => { const e = el(id); return e ? [...e.querySelectorAll('tspan')] : []; };
const stageEl = () => sr().querySelector('.stage');
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
const toScreen = (x, y) => {
const r = stageEl().getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
clientY: r.top + ((y - v.y) / v.h) * r.height };
};
const ev = (type, target, x, y, extra = {}) => {
const { clientX, clientY } = toScreen(x, y);
target.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true,
cancelable: true, pointerId: 7, clientX, clientY, button: 0, isPrimary: true, ...extra }));
};
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
c._curSpaceCfg.decor = [];
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
// ================= 1. размера шрифта в диалоге больше нет ================
c._decorTool = 'text';
c._decorTextDialog = { x: 0.3, y: 0.3, text: '', color: '#223344' };
await c.updateComplete;
out.noSizePicker = !sr().querySelector('input[name="dtsize"]');
out.textareaNotInput = !!sr().querySelector('.dialog textarea.dtarea');
c._decorTextDialog = null; await c.updateComplete;
// старый `size` продолжает рисоваться ровно как раньше: 'l' = 30px
c._curSpaceCfg.decor = [
{ id: 'dtl', kind: 'text', x: 0.25, y: 0.25, text: 'Старый', size: 'l', color: '#223344' },
{ id: 'dts', kind: 'text', x: 0.25, y: 0.4, text: 'Мелкий', size: 's', color: '#223344' },
{ id: 'dtm', kind: 'text', x: 0.25, y: 0.55, text: 'Обычный', color: '#223344' },
];
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
out.legacyLarge = +attr('dtl', 'font-size') === 30;
out.legacySmall = +attr('dts', 'font-size') === 14;
out.noSizeIsMedium = +attr('dtm', 'font-size') === 20;
// ================= 2. многострочный текст, по центру =====================
c._decorTool = 'text';
c._decorTextDialog = { x: 0.5, y: 0.5, text: ' Гараж\nпод ключ ', color: '#223344' };
c._decorSaveText(); await c.updateComplete;
const multi = c._decorList.find((x) => x.text && x.text.includes('\n'));
out.newlineStored = !!multi && multi.text === 'Гараж\nпод ключ'; // края обрезаны, середина цела
const tsp = tspans(multi.id);
out.twoLinesRendered = tsp.length === 2
&& tsp[0].textContent === 'Гараж' && tsp[1].textContent === 'под ключ';
out.linesCentredHorizontally = tsp.length === 2 && !!el(multi.id)
&& getComputedStyle(el(multi.id)).textAnchor === 'middle'
&& tsp[0].getAttribute('x') === tsp[1].getAttribute('x');
// блок центрирован и по вертикали: якорь ровно между строками
const ay = multi.y * 1000;
const y0 = tsp[0] ? +tsp[0].getAttribute('y') : NaN;
const y1 = tsp[1] ? +tsp[1].getAttribute('y') : NaN;
out.blockCentredVertically = Math.abs((y0 + y1) / 2 - ay) < 0.01 && y1 > y0;
// длинная строка НЕ переносится сама (docs/LIVE-TEXT.md: никаких автопереносов)
open(multi); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, text: 'x'.repeat(120) };
c._decorSaveText(); await c.updateComplete;
out.noAutoWrap = tspans(multi.id).length === 1;
open(c._decorList.find((x) => x.id === multi.id)); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, text: 'Гараж\nпод ключ' };
c._decorSaveText(); await c.updateComplete;
// ================= 3. ручки масштаба и поворота ==========================
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
out.noFrameWithoutSelection = !sr().querySelector('.dtframe');
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const frame = () => sr().querySelector('.dtframe');
out.frameOnSelection = !!frame();
out.fourCornersAndRotate = !!frame()
&& frame().querySelectorAll('.dthandle').length === 5
&& !!frame().querySelector('.dtrot');
// линия выделена — рамки текста нет (это рамка ТЕКСТОВОГО блока)
c._curSpaceCfg.decor = [...c._decorList, { id: 'dline', kind: 'line',
x1: 0.1, y1: 0.8, x2: 0.4, y2: 0.8, color: '#000000', width: 3 }];
c._cfgEpoch++; c._decorSel = 'dline'; c.requestUpdate();
await c.updateComplete; await sleep(40); await c.updateComplete;
out.noFrameOnALine = !sr().querySelector('.dtframe');
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
// тянем угол: масштаб растёт пропорционально расстоянию от якоря
const anchor = () => {
const sh = c._decorList.find((x) => x.id === 'dtm');
return [sh.x * 1000, sh.y * 1000];
};
const [ax, ay2] = anchor();
const corner = frame()?.querySelector('.dthandle.dt-nwse') || stageEl();
ev('pointerdown', corner, ax + 40, ay2);
out.dragStarted = !!c._dtDrag && c._dtDrag.kind === 'scale';
ev('pointermove', stageEl(), ax + 80, ay2);
await c.updateComplete;
const scaled = c._decorList.find((x) => x.id === 'dtm');
out.cornerScales = Math.abs((scaled.scale || 0) - 2) < 0.01;
out.fontFollowsScale = +attr('dtm', 'font-size') === 40;
ev('pointerup', stageEl(), ax + 80, ay2);
out.dragEnded = !c._dtDrag;
// старый `size` заменяется масштабом, который он означал (одна правда)
c._decorSel = 'dtl'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const [lx, ly] = [c._decorList.find((x) => x.id === 'dtl').x * 1000,
c._decorList.find((x) => x.id === 'dtl').y * 1000];
ev('pointerdown', frame()?.querySelector('.dthandle.dt-nwse') || stageEl(), lx + 50, ly);
ev('pointermove', stageEl(), lx + 100, ly);
await c.updateComplete;
const grown = c._decorList.find((x) => x.id === 'dtl');
out.legacySizeBecomesScale = grown.size === undefined && Math.abs((grown.scale || 0) - 3) < 0.01;
ev('pointerup', stageEl(), lx + 100, ly);
// ручка поворота: шаг 5°, Shift — мимо шага
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const [rx, ry] = anchor();
const rot = frame()?.querySelector('.dthandle.dtrot') || stageEl();
ev('pointerdown', rot, rx + 100, ry);
out.rotateStarted = c._dtDrag?.kind === 'rotate';
ev('pointermove', stageEl(), rx + 100, ry + 100); // ровно 45°
await c.updateComplete;
out.rotates = Math.abs((c._decorList.find((x) => x.id === 'dtm').angle || 0) - 45) < 1e-6;
ev('pointermove', stageEl(), rx + 100, ry + 2); // ~1.1° → прилипает к 0
await c.updateComplete;
out.snapsTo5 = (c._decorList.find((x) => x.id === 'dtm').angle || 0) === 0;
ev('pointermove', stageEl(), rx + 100, ry + 2, { shiftKey: true });
await c.updateComplete;
const free = c._decorList.find((x) => x.id === 'dtm').angle;
out.shiftGoesFree = free > 0.5 && free < 5; // тот же жест, но точный угол
ev('pointerup', stageEl(), rx + 100, ry + 2);
await c.updateComplete;
out.rotationRendered = /rotate\(/.test(attr('dtm', 'transform') || '');
// …а без Shift тот же 1.1° снова прилипает к нулю
// повернули обратно в ноль — поле исчезает, фигура снова «прямая»
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot') || stageEl(), rx + 100, ry);
ev('pointermove', stageEl(), rx + 100, ry);
ev('pointerup', stageEl(), rx + 100, ry);
await c.updateComplete;
out.zeroAngleDropped = c._decorList.find((x) => x.id === 'dtm').angle === undefined;
// ================= 4. инструмент «текст» по надписи = редактировать =======
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null;
await c.updateComplete;
const before = c._decorList.length;
const target = c._decorList.find((x) => x.id === 'dtm');
out.labelIsLiveUnderTextTool = !!el('dtm') && getComputedStyle(el('dtm')).pointerEvents !== 'none';
ev('pointerdown', el('dtm') || stageEl(), target.x * 1000, target.y * 1000);
await c.updateComplete;
out.textToolEditsTheLabel = c._decorTextDialog?.id === 'dtm'
&& c._decorTextDialog.text === target.text;
out.textToolMakesNoSecondLabel = c._decorList.length === before;
out.textToolDoesNotSelect = c._decorSel === null;
c._decorTextDialog = null; await c.updateComplete;
// по пустому месту — новая надпись
ev('pointerdown', stageEl(), 60, 940);
await c.updateComplete;
out.textToolOnEmptyCreates = !!c._decorTextDialog && !c._decorTextDialog.id;
c._decorTextDialog = null; await c.updateComplete;
// по НЕтекстовой фигуре — тоже новая надпись: она осталась инертной
const lineEl = sr().querySelector('.decorlayer line.dshape');
out.lineStillInert = getComputedStyle(lineEl).pointerEvents === 'none';
ev('pointerdown', lineEl, 250, 800);
await c.updateComplete;
out.textToolOnAShapeCreates = !!c._decorTextDialog && !c._decorTextDialog.id
&& c._decorSel === null;
c._decorTextDialog = null;
return out;
});
checkAll(res);
await finish(browser, res);
+140
View File
@@ -0,0 +1,140 @@
// Живой текст в декоре (docs/LIVE-TEXT.md): текстовая фигура может показывать
// значение ОДНОЙ сущности. Шаблон — поле text, место значения — {}; единица по
// умолчанию из сущности; мёртвая/отсутствующая сущность — прочерк «—»; ничего
// не округляем и не переформатируем. Смок проверяет, что значение читается
// живьём (новый hass — новый текст в DOM, без пересоздания карточки), что
// редактор и просмотр рисуют одно и то же и что все три поля переживают
// сохранение конфига.
// ПАДАЕТ на сборке до этой фичи: подстановки нет, в DOM остаётся сырой шаблон.
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** текст фигуры в DOM, склеенный из строк (tspan) */
const dom = (id) => {
const el = sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
if (!el) return null;
return [...el.querySelectorAll('tspan')].map((t) => t.textContent).join('\n');
};
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
/** подменить одно состояние — ровно так, как HA присылает новый hass */
const push = async (id, st) => {
const states = { ...c.hass.states };
if (st === null) delete states[id]; else states[id] = st;
c.hass = { ...c.hass, states };
await c.updateComplete;
};
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
out.decorMode = c._mode === 'decor';
c._curSpaceCfg.decor = [];
// ---------- 1. связанная и обычная надписи -------------------------------
c._decorTextDialog = { x: 0.3, y: 0.3, text: 'Бак {}', color: '#112233',
entity: 'sensor.living_temp', attr: '', unit: '' };
c._decorSaveText(); await c.updateComplete;
const live = c._decorList.find((x) => x.kind === 'text');
out.linkStored = live.entity === 'sensor.living_temp';
out.noEmptyFieldsStored = !('attr' in live) && !('unit' in live);
out.templateStoredRaw = live.text === 'Бак {}'; // в конфиге — ШАБЛОН
out.liveValueRendered = dom(live.id) === 'Бак 22.4 °C';
c._decorTextDialog = { x: 0.6, y: 0.3, text: 'Сауна', color: '#112233' };
c._decorSaveText(); await c.updateComplete;
const plain = c._decorList.find((x) => x.text === 'Сауна');
out.plainUntouched = dom(plain.id) === 'Сауна' && !('entity' in plain);
// ---------- 2. значение живое: новый hass — новый текст -------------------
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.9',
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
out.updatesOnState = dom(live.id) === 'Бак 23.9 °C';
out.plainStillUntouched = dom(plain.id) === 'Сауна';
// как отдаёт HA: ни округления, ни переформатирования
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.94781',
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
out.noRounding = dom(live.id) === 'Бак 23.94781 °C';
// ---------- 3. мёртвая сущность — прочерк, шаблон остаётся ---------------
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unavailable',
attributes: { unit_of_measurement: '°C' } });
out.deadIsDash = dom(live.id) === 'Бак —';
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unknown', attributes: {} });
out.unknownIsDash = dom(live.id) === 'Бак —';
await push('sensor.living_temp', null);
out.missingIsDash = dom(live.id) === 'Бак —';
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '22.4',
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
out.backAlive = dom(live.id) === 'Бак 22.4 °C';
// ---------- 4. атрибут и единица ----------------------------------------
open(live); await c.updateComplete;
out.dialogHasTheLink = c._decorTextDialog?.id === live.id
&& c._decorTextDialog?.entity === 'sensor.living_temp';
c._decorTextDialog = { ...c._decorTextDialog, attr: 'linkquality' };
c._decorSaveText(); await c.updateComplete;
out.attrRead = dom(live.id) === 'Бак 154';
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, unit: 'LQI' };
c._decorSaveText(); await c.updateComplete;
out.unitOverride = dom(live.id) === 'Бак 154 LQI';
// атрибута нет — прочерк, но остальная надпись жива
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, attr: 'nope', unit: '' };
c._decorSaveText(); await c.updateComplete;
out.missingAttrIsDash = dom(live.id) === 'Бак —';
// ---------- 5. без плейсхолдера значение уезжает в конец ------------------
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, text: 'Бак', attr: '', unit: '' };
c._decorSaveText(); await c.updateComplete;
out.appendedWithoutSlot = dom(live.id) === 'Бак 22.4 °C';
// ---------- 6. отвязали — снова обычная надпись --------------------------
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, text: 'Бак {}', entity: '' };
c._decorSaveText(); await c.updateComplete;
const unlinked = c._decorList.find((x) => x.id === live.id);
out.linkCleared = !('entity' in unlinked) && !('attr' in unlinked) && !('unit' in unlinked);
out.rawTemplateShown = dom(live.id) === 'Бак {}';
// ---------- 7. просмотр и киоск рисуют то же самое ------------------------
open(unlinked); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, entity: 'sensor.living_temp' };
c._decorSaveText(); await c.updateComplete;
const editorText = dom(live.id);
c._setMode('view'); await c.updateComplete; await sleep(60);
out.sameInViewMode = dom(live.id) === editorText && editorText === 'Бак 22.4 °C';
const kiosk = document.createElement('houseplan-card');
kiosk.setConfig({ type: 'custom:houseplan-card', kiosk: true });
kiosk.hass = c.hass;
document.body.appendChild(kiosk);
await kiosk.updateComplete; await sleep(400); await kiosk.updateComplete;
const kr = kiosk.shadowRoot || kiosk.renderRoot;
const kEl = kr.querySelector(`.decorlayer text.dtext[data-id="${live.id}"]`);
out.sameInKiosk = !!kEl
&& [...kEl.querySelectorAll('tspan')].map((t) => t.textContent).join('\n') === editorText;
kiosk.remove();
// ---------- 8. все три поля переживают запись конфига ---------------------
const sent = [];
const ws = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return ws(m); } };
c._setMode('decor'); await c.updateComplete;
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, attr: 'linkquality', unit: 'LQI' };
c._decorSaveText();
await sleep(900);
const set = sent.filter((m) => m.type === 'houseplan/config/set').pop();
const shape = set && (set.config.spaces.find((s) => s.id === c._space).decor || [])
.find((x) => x.id === live.id);
out.roundTrip = !!shape && shape.entity === 'sensor.living_temp'
&& shape.attr === 'linkquality' && shape.unit === 'LQI' && shape.text === 'Бак {}';
return out;
});
checkAll(res);
await finish(browser, res);
+117
View File
@@ -0,0 +1,117 @@
// Регрессия аудита v1.59.0-beta.1 — владение тёплой памяткой.
// Три находки, все три ПАДАЮТ на сборке v1.59.0-beta.1:
// AUD-159B1-01 (P2): две карточки с ИДЕНТИЧНЫМ конфигом на одной странице
// делили один слот памятки (ключ = размер окна × JSON конфига), поэтому
// новый экземпляр на месте A усыновлял вид последнего писателя — соседа B
// (его этаж, режим, зум), а черновик настоящего предшественника съедался
// guard-ом `d.mode !== this._mode`.
// AUD-159B1-02 (P2): быстрая серия пересозданий (A→B→C в одном такте):
// промежуточный B ещё не успел воскресить диалог A, но в своём
// disconnectedCallback сбрасывал `_warmRevivePending` ДО снимка и писал
// `dlg: null` поверх чужого черновика — C не получал ничего.
// AUD-159B1-03 (P3): 10-секундный TTL запрещал воскрешение, но не освобождал
// payload: просроченная запись держала диалог (у пространства это план
// целиком в base64) до перезагрузки страницы.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const HP = customElements.get('houseplan-card');
HP._warmBootReset(400); // TTL диалога 400мс вместо 10с — иначе смок стоит 10 секунд
localStorage.removeItem('houseplan_card_zoom_v1');
localStorage.removeItem('houseplan_card_nav_v1');
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
document.body.appendChild(wrap);
/** Lovelace: setConfig ДО вставки; `before` = вставить в чужой DOM-слот */
const mk = (before) => {
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__mkHass();
if (before) wrap.insertBefore(c, before); else wrap.appendChild(c);
return c;
};
const settle = async (c) => {
const t0 = performance.now();
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
await sleep(250);
};
const near = (a, b) => Math.abs(a - b) < 1e-6;
// ============ A. две одинаковые карточки не делят вид и черновик =========
const A = mk();
await settle(A);
A._applyView(2.15, 300, 420);
A.requestUpdate(); await A.updateComplete; await sleep(60);
const zoomA = A._zoom;
A._openSpaceDialog('edit', A._space); await A.updateComplete;
A._spaceDialog = { ...A._spaceDialog, title: 'DRAFT-FROM-A' };
await A.updateComplete; await sleep(30);
const B = mk(); // вторая карточка, тот же конфиг
await settle(B);
B._setMode('devices'); await B.updateComplete; await sleep(120);
B._applyView(3.35, 200, 200);
B.requestUpdate(); await B.updateComplete; await sleep(60);
const zoomB = B._zoom;
out.aTwoLiveCards = A.isConnected && B.isConnected && zoomB !== zoomA;
// B — самостоятельная карточка, а не наследник A
out.aNeighbourKeptOwnView = B._mode === 'devices' && !B._spaceDialog;
// Lovelace создаёт замену A ДО того, как отсоединит A (порядок аудитора)
const A2 = mk(A);
A.remove();
await sleep(120); await A2.updateComplete; await sleep(60);
out.aReplacementKeepsOwnMode = A2._mode === 'view'; // не 'devices' от B
out.aReplacementKeepsOwnZoom = near(A2._zoom, zoomA); // не 3.35 от B
out.aOwnersDraftRestored = A2._spaceDialog?.title === 'DRAFT-FROM-A';
out.aNeighbourUntouched = B._mode === 'devices' && near(B._zoom, zoomB) && !B._spaceDialog;
A2.remove(); B.remove(); await sleep(20);
// ============ B. быстрый двойной ре-маунт не убивает черновик ============
HP._warmBootReset(400);
let c = mk();
await settle(c);
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-RAPID' };
await c.updateComplete; await sleep(30);
out.bDraftOpenBefore = c._spaceDialog?.title === 'DRAFT-RAPID';
// A(черновик) -> создать B -> убрать A -> создать C -> убрать B — всё в одном
// такте, ни один таймер воскрешения не успевает отработать
const rb = mk(c);
c.remove();
const rc = mk(rb);
rb.remove();
await sleep(150); await rc.updateComplete; await sleep(60);
out.bDraftSurvivedDoubleRemount = rc._spaceDialog?.title === 'DRAFT-RAPID';
rc.remove(); await sleep(20);
// ============ C. просроченный диалог освобождает payload =================
HP._warmBootReset(400);
c = mk();
await settle(c);
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-TTL', planFile: 'x'.repeat(4096) };
await c.updateComplete; await sleep(30);
c.remove(); await sleep(20);
out.cHeldWhileRevivable = HP._warmBootStats().dlgs === 1; // пока TTL идёт — держим
await sleep(900); // TTL 400мс + запас
const st = HP._warmBootStats();
out.cPayloadFreedAfterTtl = st.dlgs === 0;
out.cGeometryKept = st.slots >= 0 && st.keys >= 0; // память не сломана
// и после TTL ничего не воскресает
c = mk(); await sleep(150); await c.updateComplete; await sleep(40);
out.cNoReviveAfterTtl = !c._spaceDialog;
c.remove();
HP._warmBootReset();
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
await finish(browser, res);
File diff suppressed because one or more lines are too long
+407 -325
View File
File diff suppressed because one or more lines are too long
+74
View File
@@ -1,5 +1,79 @@
# Changelog
## v1.59.0-beta.2 — 2026-08-04
The second pre-release of the 1.59 line, and it is about the words you put
on the plan. A decor label can now read a live value off an entity, so a
caption becomes a readout without becoming a template language. The text
block itself lost the choice between three font sizes: it is scaled by its
corners and turned by its handle, like every other object on the plan, and
it may have more than one line. Under that, the three findings of the
beta.1 audit are closed — two identical cards no longer share one warm
memo, a rapid double re-mount no longer eats the draft, and an expired
dialog no longer holds a plan file in memory.
- **A decor label can show a live value (docs/LIVE-TEXT.md).** A text shape
gains three optional fields — `entity`, `attr`, `unit` — and its `text`
becomes a template whose `{}` is where the value lands: `Бак {}` reads
*Бак 68 %*. Without a placeholder the value is appended; without an entity
the label is byte-for-byte the static one it always was. The unit comes
from the entity unless you type your own; a dead, unknown or missing
entity shows an em dash instead of quietly vanishing. Nothing is rounded
or reformatted — the value is what Home Assistant reports, because
rounding belongs to the sensor's `display_precision`. Not a template
language: one value, one place, no syntax to get wrong.
- **The text dialog gained an entity picker, an attribute picker and a live
preview.** The attribute list is the chosen entity's own attributes, the
unit field shows the entity's unit as its placeholder, and the preview is
rendered through the very same substitution the plan uses.
- **A text block is sized by its corners and turned by its handle.** The
choice between three font sizes is gone: select a label and pull a corner
to scale it, or use the handle above it to rotate in 5° steps (Shift for
any angle) — the same mechanics as the backdrop frame. Labels drawn with
the old Small/Medium/Large come back at exactly their old size, and the
first drag replaces that setting with the scale it meant.
- **A label can have more than one line.** The text field is a textarea now:
your line breaks are stored and drawn as line breaks, centred, with the
block growing around its anchor. Nothing wraps by itself — a caption that
reflows on a state change is a caption that jumps around the plan.
- **The text tool edits the label you press on.** Drawing tools own the
canvas (a new line must be able to start on the end of an old one), and
that stays true — with one exception: pressing an existing label with the
text tool opens ITS form instead of starting a second label on top of it.
Empty canvas and non-text shapes still create a new label.
- **Two identical cards on one page no longer share a warm memo
(AUD-159B1-01).** The memo key was the window size and the card config,
which cannot tell two placements of the same config apart: the newer of
them was the last writer, and a card re-created in the OTHER placement
woke up with its neighbour's floor, editor mode and zoom — while the
draft of its real predecessor was eaten by the mode guard. The memo now
keeps one entry per card PLACEMENT: `location.pathname` (the dashboard
view) joins the key, and inside a key the entry is claimed by DOM slot —
the parent element and the index in it — with a live neighbour's entry
never adoptable. When two placements are genuinely indistinguishable
only the settled header height is adopted (it is the same for both),
never the viewport and never the dialog.
- **A rapid double re-mount no longer destroys the draft
(AUD-159B1-02).** `disconnectedCallback()` cleared the «I still owe my
predecessor a dialog» flag BEFORE taking its snapshot, so a middle
instance in an A→B→C rebuild wrote `dlg: null` over a draft it had not
yet restored, and the third instance got nothing. The snapshot now runs
while the flag is still set: an unsaved dialog simply travels down the
chain until one instance lives long enough to reopen it.
- **An expired dialog no longer holds its payload (AUD-159B1-03).** The
10-second revive TTL was only a rule checked at revive time; the entry
itself kept the dialog — for a space dialog that is a whole plan file
as base64 — until the page reloaded. Detaching now arms a guarded
eviction that frees the payload the moment it stops being revivable
(and drops the stale slot, so the next claim is unambiguous again).
## v1.59.0-beta.1 — 2026-08-04
Minor pre-release: the card survives a Lovelace re-mount bit-for-bit —
+79
View File
@@ -6,6 +6,85 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.59.0-beta.2 — 2026-08-04
Второй пре-релиз ветки 1.59, и он про надписи на плане. Надпись в декоре
умеет показывать живое значение сущности — подпись становится показанием,
не превращаясь в язык шаблонов. Сам текстовый блок лишился выбора из трёх
размеров шрифта: он масштабируется углами и поворачивается ручкой, как
любой другой объект плана, и в нём может быть несколько строк. Под этим
закрыты три находки аудита beta.1 — две одинаковые карточки больше не
делят одну тёплую памятку, быстрый двойной ре-маунт не съедает черновик,
а просроченный диалог не держит в памяти файл плана.
- **Надпись на плане может показывать живое значение (docs/LIVE-TEXT.md).**
У текстовой фигуры появились три необязательных поля — `entity`, `attr`,
`unit`, — а её `text` стал шаблоном, в котором `{}` — место значения:
`Бак {}` читается как *Бак 68 %*. Без плейсхолдера значение добавляется в
конец; без сущности надпись остаётся ровно той статичной надписью, какой
была. Единица берётся из сущности, если не задать свою; мёртвая,
неизвестная или отсутствующая сущность показывает прочерк, а не исчезает
молча. Ничего не округляется и не переформатируется — значение такое,
каким его отдаёт Home Assistant: округление живёт в `display_precision`
сенсора, и дублировать его здесь означало бы две правды. Это не язык
шаблонов: одно значение, одно место, нечего написать неправильно.
- **В форме надписи появились пикер сущности, пикер атрибута и живой
предпросмотр.** Список атрибутов — собственные атрибуты выбранной
сущности, в поле единицы подсказкой стоит единица сущности, а
предпросмотр рисуется той же подстановкой, что и сам план.
- **Размер текстового блока задаётся углами, поворот — ручкой.** Выбор из
трёх размеров шрифта убран: выделите надпись и тяните угол, чтобы менять
размер, или крутите ручку над блоком — шаг 5°, Shift для любого угла
(механика та же, что у рамки картинки-подложки). Надписи, сделанные со
старыми «Мелкий/Средний/Крупный», возвращаются ровно в своём прежнем
размере, а первая же протяжка заменяет эту настройку тем масштабом,
который она означала.
- **В надписи может быть несколько строк.** Поле текста стало
многострочным: ваши переводы строк сохраняются и рисуются как переводы
строк, по центру, а блок растёт вокруг своей точки. Автопереносов нет —
надпись, которая перекомпоновывается при каждом изменении состояния, это
надпись, которая прыгает по плану.
- **Инструмент «текст» открывает ту надпись, по которой вы нажали.**
Рисующие инструменты владеют холстом (начало новой линии должно вставать
ровно на конец старой), и это остаётся в силе — с одним исключением:
нажатие на существующую надпись инструментом «текст» открывает ЕЁ форму,
а не создаёт вторую поверх. По пустому месту и по нетекстовой фигуре
по-прежнему создаётся новая надпись.
- **Две одинаковые карточки на одной странице больше не делят тёплую
памятку (AUD-159B1-01).** Ключом памятки были размер окна и конфиг
карточки — он не различает два РАЗМЕЩЕНИЯ одного и того же конфига:
последним писателем оказывался сосед, и карточка, пересозданная на
своём месте, просыпалась с чужим этажом, чужим режимом и чужим зумом,
а черновик её настоящего предшественника съедался проверкой режима.
Теперь памятка хранит запись на каждое РАЗМЕЩЕНИЕ: в ключ вошёл
`location.pathname` (вид дашборда), а внутри ключа запись занимается
по DOM-слоту — родительский элемент и позиция в нём, — и запись живого
соседа не усыновляется никогда. Если два размещения действительно
неразличимы, усыновляется только осевшая высота шапки (она у них
одинаковая), но не вьюпорт и не диалог.
- **Быстрый двойной ре-маунт больше не уничтожает черновик
(AUD-159B1-02).** `disconnectedCallback()` сбрасывал флаг «я ещё должен
предшественнику диалог» ДО снимка, поэтому промежуточный экземпляр в
цепочке A→B→C писал `dlg: null` поверх черновика, который сам ещё не
успел восстановить, и третьему не доставалось ничего. Снимок теперь
делается, пока флаг ещё поднят: несохранённый диалог просто едет по
цепочке, пока кто-нибудь не проживёт достаточно долго, чтобы его
открыть.
- **Просроченный диалог больше не держит свой payload (AUD-159B1-03).**
10-секундный TTL был правилом, которое проверяли только в момент
воскрешения; сама запись продолжала держать диалог — а у диалога
пространства это целый файл плана в base64 — до перезагрузки страницы.
Отсоединение теперь заводит защищённую очистку, которая освобождает
payload ровно тогда, когда он перестаёт быть воскрешаемым (и убирает
устаревший слот, чтобы следующее усыновление снова было однозначным).
## v1.59.0-beta.1 — 2026-08-04
Минорный пре-релиз: карточка переживает пересоздание в Lovelace
+179
View File
@@ -0,0 +1,179 @@
# The text block — a decor label that can show an entity's state
Status: **implemented (dev, unreleased).** Code: `src/logic.ts`
(`liveText`, `liveTextValue`, `decorTextScale`, `decorTextLines` — pure,
unit-tested), `src/houseplan-card.ts` (`_renderDecorLayer`,
`_renderTextFrame`, `_renderDecorTextDialog`, the `_dt*` gestures),
`custom_components/houseplan/validation.py` (`DECOR_SCHEMA`, text branch).
Smokes: `demo/smoke_live_text.mjs`, `demo/smoke_decor_text.mjs`;
`demo/smoke_decor.mjs` keeps the surrounding decor contract.
The shape: `{kind:'text', x, y, text, color, scale?, angle?, entity?, attr?,
unit?}` — plus the legacy `size?` that older plans still carry. Everything
after `color` is optional, so **every existing plan validates and renders
unchanged and no migration runs**.
## 1. Why a live label
The plan already answers "which lamps are on" and "how warm is the bedroom"
through icons and room fills. It cannot answer the things a house says in
words: *water tank 68 %*, *garage 12 °C*, *watering tonight at 20:00*,
*firewood left: 3 days*. Today a user who wants that puts a device marker in
"value instead of icon" mode — which gives a badge with a bare number, always
tied to a device with a position, an area and a tap action. What is missing is
the caption on the wall: free-standing text, in the user's own words, that
happens to have a live number in it.
The competing card (ha-floorplan) covers this with `text_set` and it is one of
its most used features; our decor text was one field away from it.
## 2. The live value
A decor text shape has three optional fields:
- `entity` — an entity id whose state is substituted; absent = today's plain
static label, byte-for-byte unchanged;
- `attr` — an attribute name to read instead of `state` (battery level,
current temperature of a climate, position of a cover); absent = the state;
- `unit` — a suffix; absent = the entity's own `unit_of_measurement`, so
`sensor.tank` needs no configuration to read *68 %*.
The `text` field keeps its present meaning and becomes the **template**: the
placeholder `{}` is where the value lands. `Бак {}` → *Бак 68 %*. A template
without a placeholder gets the value appended after a space, so a user who
only picks an entity and types nothing sensible still sees something useful.
**Only the first `{}` is replaced** — one label, one value.
`{}` was chosen over `{{ }}` deliberately: this is a substitution, not a
template language. There is no expression, no condition, no arithmetic — the
moment we accept `{{ states('x') | round(1) }}` we have signed up for the
class of support load that fills the competitor's thread (138 posts of "my
CSS/template does not work"). One value, one place, no syntax to get wrong.
### 2.1. Rendering rules
- The value is read live from `hass` on every render — the same source as the
rest of the card, no polling and no subscriptions of its own. A new `hass`
repaints the label; nothing is re-created.
- **Unavailable / unknown / missing entity** → the value renders as `—` (an
em dash) **and the dash carries no unit** («— °C» is not a reading); the
rest of the template stays. A label that silently disappears when a sensor
dies is worse than one that says "no data": the user must see that the
caption is alive and the sensor is not.
- An attribute that is not on the entity, or that is a dict, renders as the
same dash. A list attribute is joined with `, `; `0` and `false` are values,
not absences.
- Numbers are shown as HA reports them; we do not round, do not reformat and
do not localise decimal separators. Rounding belongs to the sensor's
`display_precision` in HA, and duplicating it here would make two sources of
truth. Imperial/metric is not our business either — the value and the unit
come from HA.
- **The unit is inherited only for the STATE.** With an `attr` the entity's
`unit_of_measurement` is not applied: it describes the state, and a
`battery_level` read off a °C sensor must not come out as «73 °C». An
explicit `unit` always wins; an empty one inherits.
- The value is clipped to `LIVE_TEXT_VALUE_MAX` (60) characters: a caption is
a caption, and an attribute that turns out to be a 4 KB string must not
become the plan's wallpaper.
- Editors and kiosk render it identically; in the decor editor the *live*
value is shown (not the raw template), so the user sees what visitors will
see while positioning it. The read-only `houseplan-space-card` does not
render the decor layer at all — that is unchanged, and out of scope here.
## 3. The block: size, rotation, lines
The old `size: 's'|'m'|'l'` selector is **gone from the dialog**. A caption's
size is not one of three opinions; it is whatever fits the place it is put in.
- **`scale`** — a font multiplier against the base 20 px, written by dragging
a corner of the selected block (select tool). Bounded `0.15…20` in the card
and in the backend.
- **`angle`** — degrees, written by the handle above the block. The step is
**5°**, the same step a device icon rotates in; **Shift** drags past it, as
past every other snap in this card (docs/CANVAS.md §9.4). Rotating back to
zero removes the field, so a straight label stores nothing.
- **No migration for `size`.** A stored `size` is read as the multiplier it
used to render at — `s` = 0.7 (14 px), `m` = 1 (20 px), `l` = 1.5 (30 px) —
so an old label comes back at exactly its old size. An explicit `scale`
wins, and the first corner drag **replaces** `size` with the scale it meant:
a shape never states its size twice. `size` stays in `DECOR_SCHEMA` (still
bounded to the three known values) precisely because old plans keep sending
it.
- **Line breaks are the user's own.** The dialog's field is a textarea; a
newline is stored and rendered as a newline (one `<tspan>` per line, line
height 1.2 em). The label **never wraps by itself** — a caption that reflows
on every state change is a caption that jumps around the plan. A
300-character line stays one line.
- **Multi-line blocks are centred**, horizontally (the decor layer's
`text-anchor: middle`, which single-line labels already used) and
vertically: the anchor `x/y` sits in the middle of the block, so adding a
second line grows the label in both directions instead of pushing the first
one up.
- Both gestures pivot on the **anchor** (`x`/`y`), not on a box corner, so a
label never walks away from the point it was placed at, and a rotated block
still scales along the same axis (a distance from the anchor is invariant
under its own rotation). The frame chrome — dashed outline, four corner
handles, one rotate handle on a stem — reuses the backdrop frame's mechanics
and sizes: chrome that never takes a pointer, handles that always do, sized
at 1.8 % of the visible view so they stay finger-sized at any zoom
(docs/BACKDROP.md §2).
- The frame is measured from the rendered glyphs (`getBBox`), so it appears
one frame after the text and follows every edit of it.
## 4. Tools: what a click does
Decor shapes are inert under a drawing tool — a new line must be able to start
exactly on the end of an old one (owner, 2026-08-04). The **text tool has one
exception**, asked for by the owner on the same day:
| Text tool, press on… | What happens |
|---|---|
| an existing **label** | its editor opens (the same form, prefilled) |
| empty canvas | a new label is created there |
| a **non-text** shape (line, rect, ellipse) | a new label is created there; the shape stays inert |
Under the **select** tool a label is selected and dragged as before, a double
click opens its editor, and the corner/rotate handles appear.
## 5. The dialog
Under the text field:
- a **textarea** (line breaks are content now), saved with the button or
Ctrl/⌘+Enter — plain Enter is a new line;
- a hint that mentions `{}` **only when an entity is chosen** — an unlinked
label must not be burdened with syntax it does not need;
- an **entity picker** with a datalist of all entities — the same control
style the vacuum source and the weather field use;
- an **attribute field**, shown only once an entity is chosen, with a datalist
of that entity's actual attribute names — the user should not have to know
that a climate keeps `current_temperature`;
- a **unit field** whose placeholder is the entity's own unit, so leaving it
empty is the obvious right answer (the placeholder disappears once an
attribute is chosen, because an attribute does not inherit it);
- a **live preview** of the resulting label, rendered through the same
`liveText` the plan uses: one substitution, one truth.
Clearing the entity clears the attribute and the unit with it — a save never
leaves orphan fields in the config.
## 6. Backend
`DECOR_SCHEMA`, text branch: `entity` optional, `None` or an entity id
(`^[a-z0-9_]+\.[a-z0-9_]+$`, ≤ 255); `attr` optional, `None` or a flat name
≤ 64; `unit` optional, `None` or ≤ 16 characters; `scale` optional, finite,
`0.15…20`; `angle` optional, finite, `-360…360`; `text` ≤ 200 characters,
newlines included. Everything optional, so every existing plan validates
unchanged. Tests: `tests_backend/test_validation.py`
(`test_decor_text_live_fields`, `test_decor_text_block_scale_and_angle`).
## 7. What this is not
- **Not a second device marker.** No tap action, no icon, no state class, no
participation in room aggregation (LQI, climate averages) — it is a caption,
not a device. A user who wants an interactive thing puts a marker.
- **Not a template engine** (see §2).
- **Not a multi-entity widget.** One label, one value. Two values are two
labels; that stays honest and costs the user one drag.
- **Not an auto-layout.** No wrapping, no shrink-to-fit: the size is set with
the corners and the lines with the Enter key.
+1 -1
View File
@@ -15,7 +15,7 @@
| Item | State |
|---|---|
| Version | **v1.59.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — **pre-release**, tag `v1.59.0-beta.1` on **`dev`**, GitHub Release with `prerelease=true`; `main` is NOT touched and **nothing is copied to the home instance by hand** — HACS delivers it on the beta channel. Contents: **warm re-mount** (docs/WARM-REMOUNT.md — a Lovelace re-creation of the card element restores the whole viewport bit-for-bit: space, mode, zoom, the `_view` rect, the view-mode snapshot, the far-objects frame, the tool, the selection and the local «show hidden»; an open dialog is revived WITH its live draft, while destructive confirmations, tap confirmations, the floor-import wizard and any dialog with a save in flight are deliberately not revived; same space + same mode, at most once, within 10 s); **the sun-ray rim** (docs/SUN.md «The rim» — a 1 px black hairline on the two SIDE edges of every lit wedge, fading on the same axis/curve/85 % threshold as the fill, `non-scaling-stroke`, peak alpha 0.42 — light finally reads on white paper; the «shade instead of light» model of docs/SUN-CONTRAST.md was REJECTED and that file now records the decision); the «+» that adds a space moved out of the Plan editor into the tab row in **every** mode (admin-only, absent in kiosk); and the «Already uploaded» plan list no longer collapses to 14 px (a scrolling flex child needs `min-height:0` overridden — docs/TESTING.md now requires new scrollable dialog containers to be smoke-tested BY GEOMETRY, not by node count). Previous stable: v1.58.0 "the backdrop you can move" |
| Version | **v1.59.0-beta.2** everywhere (manifest, const.py, package.json, CARD_VERSION) — **pre-release**, tag `v1.59.0-beta.2` on **`dev`**, GitHub Release with `prerelease=true`; `main` is NOT touched and **nothing is copied to the home instance by hand** — HACS delivers it on the beta channel. Contents on top of beta.1: **live text on a decor label** (docs/LIVE-TEXT.md — optional `entity`/`attr`/`unit` on a text shape, `{}` as the one placeholder, the unit inherited from the entity for the state only, an em dash for a dead entity, no rounding, 60 chars max; the dialog gained an entity picker, an attribute picker and a live preview); **the text block** (docs/LIVE-TEXT.md §3–5 — the three font sizes are gone: corner handles scale it and a handle rotates it in 5° steps with Shift for any angle and the pivot on the anchor, the old `size` is read as the initial scale and replaced by the first drag; explicit line breaks are drawn as line breaks, centred on both axes; the text tool opens the form of the label it is pressed on, non-text shapes stay inert); and the three findings of the beta.1 audit (docs/WARM-REMOUNT.md §1.1–1.3): the warm memo keeps one entry per card PLACEMENT (`location.pathname` in the key, a list of slots inside it with candidates ranked by DOM position) so two identical cards on one page no longer mix owners (AUD-159B1-01), the snapshot is taken BEFORE the flag is cleared so a rapid double re-mount no longer loses the draft (AUD-159B1-02), and an expired entry frees its dialog payload on a timer instead of holding a base64 plan file until reload (AUD-159B1-03). Previous stable: v1.58.0 "the backdrop you can move" |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command. **Since 2026-08-04 there is also a pre-release track:** bump to `X.Y.Z-beta.N`, tag the `dev` commit, publish a GitHub Release with `prerelease=true` — `main` is not touched and nothing is copied to the home instance by hand; HACS delivers it on the beta channel |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
+63
View File
@@ -1121,6 +1121,51 @@ require hands on real hardware — they remain for the human pass.
updates on every move, is absent before the drag has any length, and is
gone the moment the shape is committed. Rectangles and ovals show their
bounding box «W × H» instead [auto: smoke_decor]
## The text block on the plan (docs/LIVE-TEXT.md, dev, unreleased)
- [ ] **A label can show a live value**: in the Background editor place a text,
pick an entity in the dialog and write `Бак {}` — the plan shows
«Бак 68 %». Change the sensor: the label follows without any reload. A
label with no entity is untouched, byte for byte
[auto: smoke_live_text + unit logic.test]
- [ ] **The unit comes from the entity**: leave the unit field empty and the
entity's own `unit_of_measurement` is used (its placeholder shows which);
type your own and it wins. Choose an ATTRIBUTE (say `battery_level` on a
°C sensor) and the entity's unit is NOT inherited — an attribute is not
the state [auto: smoke_live_text]
- [ ] **A dead sensor says so**: make the entity unavailable (or delete it) —
the value becomes «—» and the rest of the caption stays. The dash carries
no unit [auto: smoke_live_text]
- [ ] **Nothing is rounded**: a sensor reporting `23.94781` shows `23.94781`.
Rounding is the sensor's `display_precision`, not ours [auto:
smoke_live_text]
- [ ] **The preview is the truth**: the line under the dialog's fields is
rendered by the same substitution the plan uses, so what it shows is what
the plan will show [manual]
- [ ] **The same everywhere**: the label reads identically in View, in the
editors and on a kiosk screen [auto: smoke_live_text]
- [ ] **No font-size choice any more**: the dialog has no Small/Medium/Large.
Select a label and pull a corner — the text scales uniformly about its
anchor; the handle above it turns the block in 5° steps, Shift for any
angle. Rotating back to zero leaves a straight label
[auto: smoke_decor_text]
- [ ] **Old labels keep their size**: a plan made before the handles renders
its Small/Medium/Large labels at exactly the old 14/20/30 px, and the
first corner drag converts that setting into the scale it meant
[auto: smoke_decor_text + unit logic.test]
- [ ] **Enter is a new line**: type two lines in the dialog (Ctrl/⌘+Enter or
the button saves) — the plan shows two lines, centred, growing around the
anchor. A very long single line is NOT wrapped for you
[auto: smoke_decor_text]
- [ ] **The text tool edits the label under the cursor**: with the text tool
selected, press an existing label — its form opens, prefilled, and no
second label is created. Press empty canvas, or a line/rectangle, and a
NEW label is created there instead (non-text shapes stay inert under
drawing tools) [auto: smoke_decor_text, smoke_decor]
- [ ] **A label is still a caption, not a device**: no tap action, no icon, no
part in room averages; it is not offered in any of the device pickers
[manual]
- [ ] **Grazing sunlight** (dev, audit DEV-EB173-01): with `sun_rays` on, set
the sun almost ALONG a wall carrying a window (e.g. a west window,
azimuth 190°, elevation 90° at north_deg 0). The shaft is a true
@@ -1305,3 +1350,21 @@ require hands on real hardware — they remain for the human pass.
- [ ] **A save in flight is not offered twice**: press Save in the space dialog
and reload/reconnect during the write — the dialog does not come back
with a live Save button; the reloaded config shows the outcome [manual]
- [ ] **Two identical cards keep to themselves**: put the SAME card config
twice on one view, park one in the Devices editor at 350 % and leave the
other in View with an unsaved space dialog, then force a rebuild — each
card comes back with its OWN floor, mode and zoom, and the draft returns
to the card that owned it, not to its neighbour (AUD-159B1-01)
[auto: smoke_warm_owners, section A]
- [ ] **Two dashboard views keep to themselves**: the same card config on two
views of one dashboard — switching between them never carries a viewport
or a dialog across (`location.pathname` is part of the key) [manual]
- [ ] **A rebuild storm keeps the draft**: a dashboard that rebuilds twice in a
row (config churn, a flapping websocket) still returns the unsaved dialog
— the draft travels down the chain of instances (AUD-159B1-02)
[auto: smoke_warm_owners, section B]
- [ ] **A forgotten draft frees its plan file**: open the space dialog with a
plan chosen, leave the view and do not come back — after the 10-second
TTL the memo no longer holds the dialog (a plan is base64 in memory), and
nothing revives afterwards (AUD-159B1-03)
[auto: smoke_warm_owners, section C]
+74 -5
View File
@@ -2,7 +2,7 @@
> Статус: реализовано (dev, DEV-B703-01…03). Код: `src/houseplan-card.ts`,
> модульная памятка `warmBoot`. Смоки: `demo/smoke_warm_remount.mjs`,
> `demo/smoke_warm_dialogs.mjs`.
> `demo/smoke_warm_dialogs.mjs`, `demo/smoke_warm_owners.mjs` (владение слотами).
## 1. Что вообще происходит
@@ -23,13 +23,82 @@ Lovelace **пересоздаёт элемент карточки** — выбр
который живёт в загруженном JS-модуле, а не в экземпляре. Ключ:
```
`${window.innerWidth}x${window.innerHeight}|${JSON.stringify(config)}`
`${window.innerWidth}x${window.innerHeight}|${location.pathname}|${JSON.stringify(config)}`
```
Смена размера окна → промах → полный защитный бут (единственный случай, когда
хром HA может реально пересобраться заново). Одинаковый конфиг дважды на одной
странице неразличим — это задокументированное ограничение (высоты у них всё
равно совпадают).
хром HA может реально пересобраться заново). `location.pathname` — это дашборд
И путь вида: две вкладки Lovelace никогда не делят ключ, поэтому карточка,
открытая на другом виде, стартует холодной, а не с чужим вьюпортом. Хеш в ключ
НЕ входит: `#space=` — наш собственный диплинк, а не другое размещение.
Одинаковый конфиг дважды на одном виде ключ по-прежнему не различает — этим и
занимается §1.1.
### 1.1. Одна запись — одно РАЗМЕЩЕНИЕ карточки (AUD-159B1-01)
Пока в памятке лежали два числа (высота шапки и высота сцены), «две одинаковые
карточки неразличимы» было косметикой: высоты у них всё равно совпадают. Как
только в записи появились вьюпорт и диалог, ограничение стало функциональным —
аудит v1.59.0-beta.1 воспроизвёл это так:
1. A: вид, зум 2.15, открытый диалог пространства с черновиком;
2. B (тот же конфиг): «Устройства», зум 3.35 — последний писатель общей записи;
3. Lovelace создаёт замену A′ **до** того, как отсоединит A;
4. A′ усыновляет вьюпорт B, а черновик A потом отбраковывается проверкой
«тот же режим» — пользователь видит чужой экран и теряет несохранённое.
Поэтому значение ключа теперь **список слотов**, по одному на каждое
размещение карточки. В слоте, кроме геометрии/вьюпорта/диалога, лежат:
* `owner` — поколение живого экземпляра, сидящего в слоте;
* `place` — **WeakRef** на родительский элемент, в который карточка была
вставлена (слабая ссылка: памятка не имеет права держать DOM живым);
* `idx` — позиция карточки среди детей этого родителя;
* `live` — сидит ли в слоте кто-то живой.
Слот занимается не в `setConfig` (Lovelace зовёт его **до** вставки элемента,
размещения ещё не существует), а в `connectedCallback` — всё ещё до первого
рендера, поэтому вуаль не мигает. Кандидаты ранжируются:
| Оценка | Что это | Усыновляем |
|---|---|---|
| 4 | тот же родитель и тот же индекс — буквально наш DOM-слот; именно так Lovelace подменяет карточку (предшественник может быть ещё жив) | да |
| 0 | **другое** размещение, владелец которого жив — соседняя карточка | никогда |
| 3 | тот же родитель, индекс сдвинулся, владелец ушёл | да |
| 2 | надгробие, чьё размещение исчезло вместе с поддеревом — обычная перестройка Lovelace | да |
Ничья (два одинаково правдоподобных кандидата) означает: усыновляется **только
осевшая высота шапки** — она у одинаковых карточек и так одинаковая, — но не
вьюпорт и не диалог. Это худший случай: карточка открывается без вуали, но со
своим собственным видом.
Диалог воскрешается **из своего слота**, а не «из записи по ключу», поэтому
чужой черновик физически недостижим.
### 1.2. Черновик едет по цепочке (AUD-159B1-02)
`_warmSnapshot()` не трогает `dlg`, пока у экземпляра поднят
`_warmRevivePending` — «я ещё не забрал диалог предшественника». Раньше
`disconnectedCallback()` сбрасывал этот флаг **до** снимка, и промежуточный
экземпляр в цепочке A→B→C записывал `dlg: null` поверх чужого черновика:
двойное пересоздание в одном такте теряло несохранённый ввод. Теперь снимок
делается **первым**, и черновик просто едет дальше по цепочке, пока кто-нибудь
не проживёт достаточно долго, чтобы его открыть. Регрессия:
`demo/smoke_warm_owners.mjs`, секция B.
### 1.3. TTL освобождает payload, а не только запрещает воскрешение (AUD-159B1-03)
`WARM_REVIVE_MS` (10 c) был правилом, которое проверялось только в момент
воскрешения. Запись при этом продолжала держать объект диалога — а у диалога
пространства это **целый файл плана в base64** (бэкенд разрешает 8 MiB). На
настенном планшете, который не перезагружают неделями, это десятки мегабайт
бесполезного удержания. Теперь отсоединение заводит защищённый таймер: через
`WARM_REVIVE_MS` он обнуляет `dlg`, если у слота та же метка `freed` и тот же
владелец (успел прийти преемник — таймер молчит). Высота и вьюпорт остаются:
это байты, и именно они делают следующий маунт тёплым. Заодно опустевший слот
удаляется из списка, если он там не последний — чтобы следующее усыновление
снова было однозначным.
## 2. Рывок масштаба: фактическая причина
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.59.0-beta.1",
"version": "1.59.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.59.0-beta.1",
"version": "1.59.0-beta.2",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.59.0-beta.1",
"version": "1.59.0-beta.2",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+555 -64
View File
@@ -25,6 +25,8 @@ import {
referencedContentUrls,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
coverService, coverMoving, coverEntityOf, COVER_GUARDED_CLASSES,
liveText, decorTextScale, decorTextLines,
DECOR_TEXT_BASE, DECOR_TEXT_SCALE_MIN, DECOR_TEXT_SCALE_MAX,
} from './logic';
import {
planEdgeDrag, applyEdgeDrag, clampEdgeDrag, applyRoomScale, clampRoomScale,
@@ -62,7 +64,7 @@ import {
import { alignAllToGrid, type AlignReport } from './align-grid';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.59.0-beta.1';
const CARD_VERSION = '1.59.0-beta.2';
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
* trailing-quiescence requirement (chrome still settling near the cap
@@ -119,27 +121,98 @@ type WarmViewport = {
* half-filled device dialog with its uploaded pdfs survives for free. */
type WarmDialogKind = 'space' | 'marker' | 'settings' | 'opening' | 'decorText' | 'rules' | 'room' | 'info' | 'openingInfo';
type WarmDialog = { kind: WarmDialogKind; space: string; mode: string; data: any };
/** AUD-159B1-01: one entry per CARD PLACEMENT, not per key. Two cards with an
* identical config on one view share the key, so the key alone cannot say
* whose viewport this is; `place`/`idx` (the parent element the card was
* mounted in, and its position among that parent's children) identify the
* DOM slot, and `owner` the live instance sitting in it. A re-mount into the
* same slot inherits the entry; a different card never does. */
type WarmEntry = {
/** generation id of the instance that currently owns the slot */
owner: number;
/** the parent element the owner was mounted in (weak — never keep DOM alive) */
place: WeakRef<Node> | null;
/** the owner's index among that parent's children */
idx: number;
/** the owner is attached; a dead slot is a tombstone waiting for a successor */
live: boolean;
hdrH: number;
stageH: number;
vp: WarmViewport | null;
dlg: WarmDialog | null;
/** when the instance that wrote `dlg` detached; 0 = it is still alive */
freed: number;
/** the TTL timer that frees `dlg` once it can no longer be revived */
evict: number;
};
const warmBoot = new Map<string, WarmEntry>();
const warmBoot = new Map<string, WarmEntry[]>();
let warmGen = 0;
/** `location.pathname` is the dashboard AND the view path: two Lovelace views
* never share a key, so a card that comes back on another view boots cold
* instead of inheriting a stranger's viewport (AUD-159B1-01). The hash is
* deliberately out — `#space=` is OUR deep link, not another placement. */
const warmBootKey = (config: unknown): string =>
`${window.innerWidth}x${window.innerHeight}|${JSON.stringify(config ?? {})}`;
`${window.innerWidth}x${window.innerHeight}|${location.pathname}|${JSON.stringify(config ?? {})}`;
/** A dialog is revived only if the instance that owned it died THIS long ago.
* A Lovelace rebuild detaches and re-attaches within one task; a user who
* walked off to another dashboard view and came back later must not be met
* by a dialog they have long forgotten opening. */
const WARM_REVIVE_MS = 10000;
let WARM_REVIVE_MS = 10000;
/** The memo gains a key on every window RESIZE and never loses one; the
* values used to be two numbers, and now they can hold a dialog draft (a
* plan pdf among it). Keep the last few viewports — a stale entry only
* costs the next card at that size a cold boot. */
const WARM_MAX_KEYS = 8;
/** How many placements of ONE key are remembered. More identical cards than
* this on one view and the oldest dead slot is dropped — it only costs that
* placement a cold boot. */
const WARM_MAX_SLOTS = 4;
/** Which slot of `list` belongs to the instance now claiming it, and may we
* trust it with the viewport/dialog (`sure`) or only with the settled height?
* Ranked, best first (AUD-159B1-01):
* 4 — same parent, same index: literally the DOM slot we are standing in,
* which is how Lovelace replaces a card (the predecessor may still be
* attached for another task — that is the case the audit reproduced);
* 0 — some OTHER placement whose owner is still attached: a neighbouring
* card with an identical config, never ours to inherit;
* 3 — same parent, shifted index, owner gone: still our placement;
* 2 — a tombstone whose placement is gone with its subtree — the ordinary
* Lovelace rebuild, where the container is rebuilt too.
* A tie means two candidates are equally plausible: then only the settled
* height is adopted, and it is the same for all of them anyway. */
const warmMatch = (
list: WarmEntry[],
gen: number,
place: Node | null,
idx: number,
): { slot: WarmEntry | null; sure: boolean } => {
const score = (s: WarmEntry): number => {
const same = !!place && s.place?.deref() === place;
if (same && s.idx === idx) return 4;
if (s.live) return 0;
return same ? 3 : 2;
};
let best: WarmEntry | null = null;
let bestScore = 0;
let ties = 0;
let newest: WarmEntry | null = null;
for (const s of list) {
if (s.owner === gen) continue;
newest = s;
const sc = score(s);
if (sc <= 0) continue;
if (sc > bestScore) { best = s; bestScore = sc; ties = 1; }
else if (sc === bestScore) ties++;
}
if (!best || ties > 1) return { slot: best || newest, sure: false };
return { slot: best, sure: true };
};
/** Rotation step of a decor text block — the same 5° a device icon turns in
* (marker dialog). Shift drags past it, as past every other snap. */
const DT_ANGLE_STEP = 5;
/** Line spacing of a multi-line label, in font sizes. */
const DT_LINE = 1.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';
@@ -268,7 +341,23 @@ class HouseplanCard extends LitElement {
private _decorDraft: { kind: 'line' | 'rect' | 'ellipse'; a: number[]; b: number[]; pid: number } | null = null;
private _decorMove: { id: string; start: number[]; orig: any; pid: number; moved: boolean } | null = null;
private _decorSel: string | null = null;
private _decorTextDialog: { id?: string; x: number; y: number; text: string; size: 's' | 'm' | 'l'; color: string } | null = null;
/** The text dialog. `size` is gone from the UI (the block is scaled by its
* corners now); `entity`/`attr`/`unit` are the live link (docs/LIVE-TEXT.md). */
private _decorTextDialog: {
id?: string; x: number; y: number; text: string; color: string;
entity?: string; attr?: string; unit?: string;
} | null = null;
/** The measured box of the selected text block, its own (unrotated) frame. */
private _dtBox: { id: string; x: number; y: number; w: number; h: number } | null = null;
/** A live corner (scale) or rotate gesture on the selected text block. */
private _dtDrag: {
id: string; kind: 'scale' | 'rotate'; pid: number;
/** the anchor the block is scaled and rotated about (render units) */
ax: number; ay: number;
/** distance / bearing of the pointer at the start, and the stored values */
r0: number; a0: number; scale0: number; angle0: number;
moved: boolean;
} | null = null;
/**
* The live backdrop gesture (docs/BACKDROP.md §2): moving the picture by its
* body, or scaling it UNIFORMLY by a corner handle about the opposite corner.
@@ -572,6 +661,11 @@ class HouseplanCard extends LitElement {
private _warmVpArmed = false;
private _warmRevivePending = false;
private _warmReviveTimer?: number;
/** AUD-159B1-01: this instance's identity in the memo — its generation, the
* key it settled under and the slot (card placement) it owns. */
private _warmGen = ++warmGen;
private _warmKey: string | null = null;
private _warmSlot: WarmEntry | null = null;
private _hashApplied = false;
private _navApplied = false; // the saved space was restored (or the user navigated)
// ---- kiosk (wall device) mode ----
@@ -670,6 +764,8 @@ class HouseplanCard extends LitElement {
_decorSel: { state: true },
_decorTextDialog: { state: true },
_bdDrag: { state: true },
_dtBox: { state: true },
_dtDrag: { state: true },
_kioskDialog: { state: true },
_vacFit: { state: true },
_kioskDots: { state: true },
@@ -724,6 +820,8 @@ class HouseplanCard extends LitElement {
// DEV-B703-02: a reattach mid-outage must keep revalidating — the retry
// timer died in disconnectedCallback
if (!this._loadOk && this._serverCfg && this.hass) this._scheduleLoadRetry();
// AUD-159B1-01: the placement is only knowable once we are IN the DOM.
if (!this._warmSlot && this._config) this._warmAdopt();
// DEV-B703-03: one task later the element Lovelace replaced has detached
// — only then is its open dialog ours to take over.
if (this._warmVp && !this._warmRevivePending && this._warmReviveTimer === undefined) {
@@ -774,11 +872,18 @@ class HouseplanCard extends LitElement {
clearTimeout(this._layoutSyncTimer);
// DEV-B703-03: the last thing this instance was showing, then the
// tombstone that lets exactly one successor adopt the open dialog.
// AUD-159B1-02: the snapshot runs while `_warmRevivePending` is still
// TRUE. An instance that never got to consume its predecessor's dialog
// has nothing of its own to record there, and the old order (clear the
// flag, then snapshot) had it write `dlg: null` over somebody else's
// unsaved draft — a second Lovelace rebuild inside one task destroyed
// the draft before the first successor could restore it. Now the draft
// simply travels down the chain until one of them lives long enough.
this._warmSnapshot();
this._warmRevivePending = false;
clearTimeout(this._warmReviveTimer);
this._warmReviveTimer = undefined;
this._warmSnapshot();
this._warmPatch({ freed: Date.now() });
this._warmRelease();
super.disconnectedCallback();
}
@@ -893,33 +998,32 @@ class HouseplanCard extends LitElement {
return { type: 'custom:houseplan-card' };
}
/** Test hook (smokes): forget the warm re-mount memo — a cold page again. */
public static _warmBootReset(): void {
/** Test hook (smokes): forget the warm re-mount memo — a cold page again.
* `ttl` retunes WARM_REVIVE_MS so a smoke can watch the TTL expire without
* standing still for ten seconds (AUD-159B1-03); omitted = back to 10 s. */
public static _warmBootReset(ttl?: number): void {
for (const list of warmBoot.values()) for (const s of list) clearTimeout(s.evict);
warmBoot.clear();
WARM_REVIVE_MS = ttl && ttl > 0 ? ttl : 10000;
}
/** Test hook (smokes): what the memo is holding — slots per key, and how
* many of them still keep a dialog payload alive. */
public static _warmBootStats(): { keys: number; slots: number; dlgs: number; drafts: string[] } {
let slots = 0, dlgs = 0;
const drafts: string[] = [];
for (const list of warmBoot.values()) {
for (const s of list) {
slots++;
if (s.dlg) { dlgs++; drafts.push(s.dlg.kind); }
}
}
return { keys: warmBoot.size, slots, dlgs, drafts };
}
public setConfig(config: CardConfig): void {
this._config = { icon_size: 2.5, show_temperature: true, live_states: true, show_signal: true, ...config };
if (this._config.kiosk) { this._booting = false; this._bootFading = false; } // kiosk: 100dvh, nothing to settle
else {
// DEV-B703-01: this page already booted an identical card at this
// viewport — adopt its settled header height and skip the veil
// entirely: the card reveals synchronously in the final geometry (the
// saved zoom is armed below, HP-1551). _bootSoft covers any residual
// chrome drift with a glide instead of a snap.
const warm = warmBoot.get(warmBootKey(this._config));
if (warm) {
this._booting = false;
this._bootFading = false;
this._hdrH = warm.hdrH;
this._warmVp = warm.vp; // adopted below, once the model is in hand
this._bootSoft = true; // timer armed in connectedCallback...
if (this.isConnected) { // ...unless setConfig re-runs while attached
clearTimeout(this._bootSoftTimer);
this._bootSoftTimer = window.setTimeout(() => { this._bootSoft = false; }, BOOT_SOFT_MS);
}
}
}
if (config.default_floor) this._space = config.default_floor;
try {
this._zoomBySpace = JSON.parse(localStorage.getItem(LS_ZOOM) || '{}') || {};
@@ -962,7 +1066,127 @@ class HouseplanCard extends LitElement {
// arm it BEFORE the first view computation, so the very first paint is
// already at the user's zoom.
if (this._mode === 'view' && !this._view) this._zoom = this._zoomBySpace[this._space] || 1;
this._warmAdoptViewport(config);
// AUD-159B1-01: the memo is claimed by DOM slot, and Lovelace calls
// setConfig BEFORE it inserts the element — so the claim waits for
// connectedCallback (still before the first render, so nothing flashes).
// A setConfig on a card that is already attached (the editor's live
// preview) re-claims right here, because the config is part of the key.
if (this.isConnected) this._warmAdopt();
}
/**
* DEV-B703-01/03 + AUD-159B1-01: find the memo of the card that used to sit
* in THIS placement and step into it — the settled header height (no veil,
* synchronous reveal in the final geometry) and, when the slot is provably
* ours, the whole viewport. `_bootSoft` covers residual chrome drift with a
* glide instead of a snap.
*/
private _warmAdopt(): void {
if (this._config?.kiosk) return;
const key = warmBootKey(this._config);
if (this._warmKey === key && this._warmSlot) return; // already sitting in it
if (this._warmSlot) this._warmRelease(); // the config (or the window) changed under us
const place = this.parentNode;
const idx = this._warmIdx(place);
const list = warmBoot.get(key);
if (!list || !list.length) return; // cold page — the full protective boot
// the same element re-attached (Lovelace moves cards around): our own slot
// is waiting for us, and our live state is newer than anything in it
const mine = list.find((s) => s.owner === this._warmGen);
if (mine) {
clearTimeout(mine.evict); mine.evict = 0; mine.freed = 0; mine.live = true;
this._warmSlot = mine;
this._warmKey = key;
return;
}
const { slot, sure } = warmMatch(list, this._warmGen, place, idx);
if (!slot) return;
this._booting = false;
this._bootFading = false;
this._hdrH = slot.hdrH;
this._bootSoft = true; // timer armed in connectedCallback...
if (this.isConnected) { // ...unless we are claiming while already attached
clearTimeout(this._bootSoftTimer);
this._bootSoftTimer = window.setTimeout(() => { this._bootSoft = false; }, BOOT_SOFT_MS);
}
this._warmKey = key;
if (sure) {
clearTimeout(slot.evict); // the dialog is ours now, not the TTL's
slot.evict = 0;
slot.owner = this._warmGen;
slot.place = place ? new WeakRef(place) : null;
slot.idx = idx;
slot.live = true;
this._warmSlot = slot;
this._warmVp = slot.vp; // adopted below, once the model is in hand
this._warmAdoptViewport(this._config!);
} else {
// Two identical cards on one view and no way to tell which slot is ours:
// the height is interchangeable (they settle at the same chrome), the
// viewport and the dialog belong to somebody and must not be guessed.
this._warmSlot = {
owner: this._warmGen, place: place ? new WeakRef(place) : null, idx, live: true,
hdrH: slot.hdrH, stageH: slot.stageH, vp: null, dlg: null, freed: 0, evict: 0,
};
list.push(this._warmSlot);
this._warmTrim(list);
}
}
/** This element's position among its parent's children (-1 if unmounted). */
private _warmIdx(place: Node | null): number {
const kids = (place as Element | null)?.children;
if (!kids) return -1;
for (let i = 0; i < kids.length; i++) if (kids[i] === this) return i;
return -1;
}
/** Let go of the slot: the placement is still there, this instance is not. */
private _warmRelease(): void {
const s = this._warmSlot;
const key = this._warmKey;
this._warmSlot = null;
this._warmKey = null;
if (!s || !key) return;
s.freed = Date.now();
if (s.owner === this._warmGen) s.live = false;
this._warmScheduleEvict(s, key);
}
/** Drop the oldest slot that nobody is sitting in. */
private _warmTrim(list: WarmEntry[]): void {
while (list.length > WARM_MAX_SLOTS) {
const i = list.findIndex((s) => !s.live);
if (i < 0) break;
clearTimeout(list[i].evict);
list.splice(i, 1);
}
}
/**
* AUD-159B1-03: the TTL used to be a rule checked at revive time only, so a
* draft nobody came back for stayed reachable from module scope until the
* page reloaded — with the space dialog that means a whole plan file held as
* base64 (8 MiB allowed by the backend). Free the payload the moment it
* stops being revivable; the height and the viewport are bytes, they stay.
*/
private _warmScheduleEvict(s: WarmEntry, key: string): void {
clearTimeout(s.evict);
if (!s.dlg) return;
const freed = s.freed;
const gen = s.owner;
s.evict = window.setTimeout(() => {
s.evict = 0;
if (s.freed !== freed || s.owner !== gen) return; // a successor took over
// the dialog is unrevivable now, whoever is sitting in the slot
s.dlg = null;
// a slot nobody re-claimed is also what makes the NEXT claim ambiguous
const list = warmBoot.get(key);
if (!s.live && list && list.length > 1) {
const i = list.indexOf(s);
if (i >= 0) list.splice(i, 1);
}
}, WARM_REVIVE_MS + 250);
}
/**
@@ -1003,14 +1227,31 @@ class HouseplanCard extends LitElement {
private _warmPatch(patch: Partial<WarmEntry>, create = false): void {
if (this._config?.kiosk) return;
const k = warmBootKey(this._config);
const cur = warmBoot.get(k);
if (!cur && !create) return;
warmBoot.set(k, { hdrH: this._hdrH, stageH: 0, vp: null, dlg: null, freed: 0, ...(cur || {}), ...patch });
while (warmBoot.size > WARM_MAX_KEYS) {
const oldest = warmBoot.keys().next().value; // Map keeps insertion order
if (oldest === undefined || oldest === k) break;
warmBoot.delete(oldest);
// The key carries the window size and the view path: once either changes
// under a LIVE card, its slot describes a geometry that is no longer on
// screen, and the next mount at the new size must boot cold (documented
// in docs/WARM-REMOUNT.md §1) — so write nothing rather than lie.
if (this._warmSlot && this._warmKey !== k) return;
if (!this._warmSlot) {
if (!create) return;
const place = this.parentNode;
this._warmKey = k;
this._warmSlot = {
owner: this._warmGen, place: place ? new WeakRef(place) : null, idx: this._warmIdx(place), live: true,
hdrH: this._hdrH, stageH: 0, vp: null, dlg: null, freed: 0, evict: 0,
};
const list = warmBoot.get(k) || [];
list.push(this._warmSlot);
warmBoot.set(k, list);
this._warmTrim(list);
while (warmBoot.size > WARM_MAX_KEYS) {
const oldest = warmBoot.keys().next().value; // Map keeps insertion order
if (oldest === undefined || oldest === k) break;
for (const s of warmBoot.get(oldest) || []) clearTimeout(s.evict);
warmBoot.delete(oldest);
}
}
Object.assign(this._warmSlot, patch);
}
private _warmViewportState(): WarmViewport {
@@ -1078,6 +1319,13 @@ class HouseplanCard extends LitElement {
const patch: Partial<WarmEntry> = { vp: this._warmViewportState() };
// do not overwrite the snapshot we are about to revive FROM
if (!this._warmRevivePending) patch.dlg = this._warmDialogState();
// AUD-159B1-01: the placement is re-measured from the live DOM, so a card
// that was moved (a sibling added above it) still names its own slot.
if (this.isConnected && this._warmSlot?.owner === this._warmGen) {
const place = this.parentNode;
patch.place = place ? new WeakRef(place) : null;
patch.idx = this._warmIdx(place);
}
this._warmPatch(patch);
}
@@ -1089,12 +1337,14 @@ class HouseplanCard extends LitElement {
*/
private _warmReviveDialog(): void {
this._warmRevivePending = false;
const k = warmBootKey(this._config);
const e = warmBoot.get(k);
const e = this._warmSlot; // AUD-159B1-01: OUR slot, never a neighbour's
this._warmReviveTimer = undefined;
if (!e || !e.dlg) return;
const d = e.dlg;
warmBoot.set(k, { ...e, dlg: null, freed: 0 }); // consume-once: no zombie on the third mount
if (!e.freed || Date.now() - e.freed > WARM_REVIVE_MS) return; // owner alive, or gone long ago
const freed = e.freed;
e.dlg = null; e.freed = 0; // consume-once: no zombie on the third mount
clearTimeout(e.evict); e.evict = 0;
if (!freed || Date.now() - freed > WARM_REVIVE_MS) return; // owner alive, or gone long ago
if (d.space !== this._space || d.mode !== this._mode) return; // never in another space/editor
switch (d.kind) {
case 'space': this._spaceDialog = { ...d.data, busy: false, savedBusy: false }; break;
@@ -1329,6 +1579,7 @@ class HouseplanCard extends LitElement {
protected updated(): void {
this._skyRelease();
this._warmSnapshot(); // DEV-B703-03: the memo follows what is on screen
this._dtMeasure(); // the selected label's frame follows the glyphs
const stage = this._stageEl;
if (stage && !this._roViewport) {
this._roViewport = new ResizeObserver(() => this._refitView());
@@ -2570,6 +2821,10 @@ class HouseplanCard extends LitElement {
}
private _stagePointerMove(ev: PointerEvent): void {
if (this._dtDrag?.pid === ev.pointerId) {
this._dtMove(ev);
return;
}
if (this._bdDrag?.pid === ev.pointerId) {
this._bdMove(ev);
return;
@@ -2672,6 +2927,10 @@ class HouseplanCard extends LitElement {
}
}
}
if (this._dtDrag?.pid === ev.pointerId) {
this._dtUp();
return;
}
if (this._bdDrag?.pid === ev.pointerId) {
this._bdUp();
return;
@@ -2942,6 +3201,8 @@ class HouseplanCard extends LitElement {
this._decorSel = null;
this._decorTool = 'select';
this._bdDrag = null;
this._dtDrag = null;
this._dtBox = null;
this._saveNav();
}
@@ -3487,10 +3748,12 @@ class HouseplanCard extends LitElement {
return true;
}
if (t === 'text') {
// …and the press did NOT land on an existing label: those are the one
// exception to the inertness above (see _decorShapeDown).
const p = this._snap(this._svgPoint(ev), ev);
this._decorTextDialog = {
x: clampCanvasN(p[0] / NORM_W), y: clampCanvasN(p[1] / this._decorH),
text: '', size: 'm', color: this._decorStyle.color,
text: '', color: this._decorStyle.color,
};
return true;
}
@@ -3547,6 +3810,19 @@ class HouseplanCard extends LitElement {
// backdrop tool grabs the picture). Swallowing it here was the bug — the
// click on a line end did nothing but keep the old selection alive.
const t = this._decorTool;
// ONE exception (owner, 2026-08-04): under the TEXT tool an existing LABEL
// is a target again, and pressing it opens its editor instead of starting
// a new label on top of it. Everything else stays inert — a press on a
// line or a rectangle under the text tool still reaches the stage and
// creates a new label there (the CSS keeps them pointer-inert, this is
// the belt to that pair of braces).
if (t === 'text') {
if (shape.kind !== 'text') return;
ev.stopPropagation();
ev.preventDefault();
this._decorOpenText(shape);
return;
}
if (t !== 'select' && t !== 'erase') return;
ev.stopPropagation();
ev.preventDefault();
@@ -3606,27 +3882,171 @@ class HouseplanCard extends LitElement {
/** Double click on a text shape (select tool) re-opens its dialog. */
private _decorShapeDbl(shape: any): void {
if (this._mode !== 'decor' || this._decorTool !== 'select' || shape.kind !== 'text') return;
this._decorTextDialog = { id: shape.id, x: shape.x, y: shape.y,
text: shape.text, size: shape.size || 'm', color: shape.color };
this._decorOpenText(shape);
}
/** Open the editor of an existing label (double click, or the text tool). */
private _decorOpenText(shape: any): void {
this._decorTextDialog = {
id: shape.id, x: shape.x, y: shape.y, text: shape.text, color: shape.color,
entity: shape.entity || '', attr: shape.attr || '', unit: shape.unit || '',
};
}
/** The link fields as they are STORED: only what the user actually filled in
* ends up in the config, so a plain label stays byte-for-byte a plain one. */
private _decorTextLink(d: { entity?: string; attr?: string; unit?: string }): Record<string, string> {
const out: Record<string, string> = {};
const ent = (d.entity || '').trim();
if (!ent) return out;
out.entity = ent;
const attr = (d.attr || '').trim();
if (attr) out.attr = attr;
const unit = (d.unit || '').trim();
if (unit) out.unit = unit;
return out;
}
private _decorSaveText(): void {
const d = this._decorTextDialog;
if (!d || !d.text.trim()) { this._decorTextDialog = null; return; }
// The user's own line breaks are kept; the surrounding whitespace is not.
const text = String(d?.text ?? '').replace(/\r\n?/g, '\n').trim();
if (!d || !text) { this._decorTextDialog = null; return; }
const sp = this._curSpaceCfg;
const link = this._decorTextLink(d);
if (d.id) {
sp.decor = this._decorList.map((x) => x.id === d.id
? { ...x, text: d.text.trim(), size: d.size, color: d.color } : x);
sp.decor = this._decorList.map((x) => {
if (x.id !== d.id) return x;
// the OLD link is dropped wholesale: clearing the entity in the dialog
// must clear the attribute and the unit with it, not leave orphans
const { entity, attr, unit, ...rest } = x;
return { ...rest, text, color: d.color, ...link };
});
} else {
const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
sp.decor = [...this._decorList, { id, kind: 'text', x: d.x, y: d.y,
text: d.text.trim(), size: d.size, color: d.color }];
text, color: d.color, ...link }];
this._decorSel = id;
}
this._decorTextDialog = null;
this._saveConfig();
this.requestUpdate();
}
// ---- the text block: scale by a corner, rotate by its handle ----
// The mechanics are the backdrop frame's (docs/BACKDROP.md §2), reused
// rather than reinvented: chrome that never takes a pointer, finger-sized
// handles that always do, the gesture written live into the config and
// PERSISTED only if something actually moved. What differs is the pivot —
// a label has an anchor (its x/y), not a box, so both gestures are about
// that anchor and the text never walks away from the point it was placed at.
/** The selected text shape, when the select tool has one. */
private get _dtSel(): any | null {
if (this._mode !== 'decor' || this._decorTool !== 'select' || !this._decorSel) return null;
const sh = this._decorList.find((x) => x.id === this._decorSel);
return sh && sh.kind === 'text' ? sh : null;
}
/** Write scale/angle into the shape — live, without saving. */
private _dtApply(id: string, patch: { scale?: number; angle?: number }): void {
const sp = this._curSpaceCfg;
if (!sp) return;
sp.decor = this._decorList.map((x) => {
if (x.id !== id) return x;
// the first drag replaces the legacy font size with the scale it means,
// so a shape never states its size twice
const { size, ...rest } = x;
const out: any = { ...rest };
if (patch.scale !== undefined) out.scale = Number(patch.scale.toFixed(4));
else if (x.scale === undefined && size !== undefined) out.scale = decorTextScale(x);
if (patch.angle !== undefined) {
if (patch.angle) out.angle = Number(patch.angle.toFixed(2));
else delete out.angle; // straight again = the field goes away
}
return out;
});
this._cfgEpoch++;
this.requestUpdate();
}
private _dtStart(ev: PointerEvent, kind: 'scale' | 'rotate'): void {
const sh = this._dtSel;
if (!sh) return;
ev.stopPropagation();
ev.preventDefault();
const ax = sh.x * NORM_W, ay = sh.y * this._decorH;
const p = this._svgPoint(ev);
this._dtDrag = {
id: sh.id, kind, pid: ev.pointerId, ax, ay,
r0: Math.hypot(p[0] - ax, p[1] - ay),
a0: (Math.atan2(p[1] - ay, p[0] - ax) * 180) / Math.PI,
scale0: decorTextScale(sh), angle0: Number(sh.angle) || 0,
moved: false,
};
capturePointer(ev);
}
private _dtMove(ev: PointerEvent): void {
const d = this._dtDrag;
if (!d) return;
const p = this._svgPoint(ev);
if (d.kind === 'scale') {
// uniform, about the anchor: the distance from the anchor is invariant
// under the block's own rotation, so a rotated block scales the same way
const r = Math.hypot(p[0] - d.ax, p[1] - d.ay);
if (d.r0 < 1e-6) return;
const k = Math.min(DECOR_TEXT_SCALE_MAX, Math.max(DECOR_TEXT_SCALE_MIN, d.scale0 * (r / d.r0)));
if (Math.abs(k - d.scale0) > 1e-6) d.moved = true;
this._dtApply(d.id, { scale: k });
return;
}
const a = (Math.atan2(p[1] - d.ay, p[0] - d.ax) * 180) / Math.PI;
let ang = d.angle0 + (a - d.a0);
// 5° steps — the step the device icons rotate in; Shift is the way past a
// snap everywhere in this card (docs/CANVAS.md §9.4), so it is here too
if (!ev.shiftKey) ang = Math.round(ang / DT_ANGLE_STEP) * DT_ANGLE_STEP;
ang = ((ang % 360) + 360) % 360;
if (ang > 180) ang -= 360;
if (Math.abs(ang - d.angle0) > 1e-6) d.moved = true;
this._dtApply(d.id, { angle: ang });
}
private _dtUp(): void {
const d = this._dtDrag;
this._dtDrag = null;
if (d?.moved) this._saveConfig();
this.requestUpdate();
}
/**
* Measure the selected label's own box. SVG can only tell us how big a text
* actually came out once it is in the DOM, so the frame is one render
* behind — and it is re-measured whenever the text, its scale or the
* selection changes. Guarded against the render→measure→render loop by
* comparing the numbers before asking for another update.
*/
private _dtMeasure(): void {
const sh = this._dtSel;
if (!sh) {
if (this._dtBox) { this._dtBox = null; this.requestUpdate(); }
return;
}
const el = this.renderRoot.querySelector(`text.dtext[data-id="${sh.id}"]`) as SVGGraphicsElement | null;
if (!el || typeof (el as any).getBBox !== 'function') return;
let b: DOMRect;
try { b = el.getBBox(); } catch { return; } // not rendered yet (hidden card)
if (!b || (!b.width && !b.height)) return;
const box = { id: sh.id, x: b.x, y: b.y, w: b.width, h: b.height };
const cur = this._dtBox;
const same = cur && cur.id === box.id && Math.abs(cur.x - box.x) < 0.01
&& Math.abs(cur.y - box.y) < 0.01 && Math.abs(cur.w - box.w) < 0.01
&& Math.abs(cur.h - box.h) < 0.01;
if (same) return;
this._dtBox = box;
this.requestUpdate();
}
private _decorDeleteSel(): void {
if (!this._decorSel) return;
const sp = this._curSpaceCfg;
@@ -3836,9 +4256,37 @@ class HouseplanCard extends LitElement {
</g>` as unknown as TemplateResult;
}
/**
* The selected text block's frame: a dashed outline, four corner handles
* that scale it uniformly and one handle above it that turns it. Same
* mechanics and same handle size as the backdrop frame (docs/BACKDROP.md
* §2) — finger-sized in SCREEN terms, so it stays grabbable at any zoom —
* and it rides the block's own rotation, so the corners stay at the corners.
*/
private _renderTextFrame(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing {
const sh = this._dtSel;
const b = this._dtBox;
if (!sh || !b || b.id !== sh.id) return nothing;
const hr = Math.max(view.w, view.h) * 0.018;
const ax = sh.x * NORM_W, ay = sh.y * this._decorH;
const ang = Number(sh.angle) || 0;
const corners: [number, number, string][] = [
[-1, -1, 'nwse'], [1, -1, 'nesw'], [1, 1, 'nwse'], [-1, 1, 'nesw'],
];
const arm = hr * 2.2;
return svg`<g class="dtframe" transform=${ang ? `rotate(${ang} ${ax} ${ay})` : nothing}>
<rect class="dtbox" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}"></rect>
<line class="dtstem" x1="${b.x + b.w / 2}" y1="${b.y}" x2="${b.x + b.w / 2}" y2="${b.y - arm}"></line>
<circle class="dthandle dtrot" cx="${b.x + b.w / 2}" cy="${b.y - arm}" r="${hr.toFixed(1)}"
@pointerdown=${(e: PointerEvent) => this._dtStart(e, 'rotate')}></circle>
${corners.map(([sx, sy, cur]) => svg`<circle class="dthandle dt-${cur}"
cx="${sx < 0 ? b.x : b.x + b.w}" cy="${sy < 0 ? b.y : b.y + b.h}" r="${hr.toFixed(1)}"
@pointerdown=${(e: PointerEvent) => this._dtStart(e, 'scale')}></circle>`)}
</g>` as unknown as TemplateResult;
}
private _renderDecorLayer(): TemplateResult {
const W = NORM_W, H = this._decorH;
const TXT = { s: 14, m: 20, l: 30 } as Record<string, number>;
const editing = this._mode === 'decor';
const shapes = this._decorList.map((sh) => {
const cls = 'dshape' + (editing && this._decorSel === sh.id ? ' dsel' : '');
@@ -3857,9 +4305,25 @@ class HouseplanCard extends LitElement {
return svg`<ellipse class="${cls}" cx="${(sh.x + sh.w / 2) * W}" cy="${(sh.y + sh.h / 2) * H}"
rx="${(sh.w / 2) * W}" ry="${(sh.h / 2) * H}" stroke="${sh.color}" stroke-width="${sh.width}"
fill="${sh.fill ? sh.color : 'none'}" fill-opacity="${sh.fill ? 0.25 : 0}" @pointerdown=${down}></ellipse>`;
if (sh.kind === 'text')
return svg`<text class="${cls} dtext" x="${sh.x * W}" y="${sh.y * H}" fill="${sh.color}"
font-size="${TXT[sh.size] || TXT.m}" @pointerdown=${down} @dblclick=${dbl}>${sh.text}</text>`;
if (sh.kind === 'text') {
// The label is painted from the LIVE value on every render — the same
// `hass` the rest of the card reads, no polling of its own. Without an
// entity `liveText` gives the stored text back byte-for-byte, so a
// plain label is the plain label it always was (docs/LIVE-TEXT.md).
const fs = DECOR_TEXT_BASE * decorTextScale(sh);
const lines = decorTextLines(liveText(sh.text, sh, this.hass));
const ax = sh.x * W, ay = sh.y * H;
const ang = Number(sh.angle) || 0;
// the block is centred on its anchor, horizontally (the layer's
// text-anchor) and vertically — so adding a second line grows the
// label in both directions instead of pushing the first one up
const y0 = ay - ((lines.length - 1) * fs * DT_LINE) / 2;
return svg`<text class="${cls} dtext" data-id="${sh.id}" x="${ax}" y="${ay}" fill="${sh.color}"
font-size="${fs}" transform=${ang ? `rotate(${ang} ${ax} ${ay})` : nothing}
@pointerdown=${down} @dblclick=${dbl}>${lines.map(
(ln, i) => svg`<tspan x="${ax}" y="${y0 + i * fs * DT_LINE}">${ln}</tspan>`,
)}</text>`;
}
return nothing;
});
// живое превью рисуемой фигуры
@@ -3940,27 +4404,53 @@ class HouseplanCard extends LitElement {
private _renderDecorTextDialog(): TemplateResult {
const d = this._decorTextDialog!;
const ent = (d.entity || '').trim();
const st = ent ? this.hass?.states?.[ent] : null;
const ownUnit = String(st?.attributes?.unit_of_measurement ?? '');
// the preview is the REAL renderer: one substitution, one truth
const preview = liveText(d.text, d, this.hass);
return html`<div class="menuwrap dialogwrap" @click=${() => (this._decorTextDialog = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:format-text"></ha-icon>${this._t('decor.text_title')}</div>
<div class="body">
<label>${this._t('decor.text_label')}</label>
<input class="namein" .value=${d.text} autofocus
@input=${(e: Event) => (this._decorTextDialog = { ...d, text: (e.target as HTMLInputElement).value })}
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') this._decorSaveText(); }} />
<label>${this._t('decor.text_size')}</label>
<div class="radiorow">
${(['s', 'm', 'l'] as const).map(
(sz) => html`<label class="srcrow inline">
<input type="radio" name="dtsize" .checked=${d.size === sz}
@change=${() => (this._decorTextDialog = { ...d, size: sz })} />
<span>${this._t(('decor.size_' + sz) as any)}</span>
</label>`,
)}
</div>
${''/* a textarea, not an input: the user's own line breaks are kept
and rendered (centred). Enter is a NEW LINE here, so saving
moved to Ctrl/Cmd+Enter and the button. */}
<textarea class="namein dtarea" rows="3" maxlength="200" .value=${d.text} autofocus
@input=${(e: Event) => (this._decorTextDialog = { ...d, text: (e.target as HTMLTextAreaElement).value })}
@keydown=${(e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) this._decorSaveText();
}}></textarea>
<div class="rhint">${this._t(ent ? 'decor.text_hint_live' : 'decor.text_hint')}</div>
<label>${this._t('decor.color')}</label>
<input type="color" .value=${d.color}
@input=${(e: Event) => (this._decorTextDialog = { ...d, color: (e.target as HTMLInputElement).value })} />
<label class="dispsection">${this._t('decor.live_group')}</label>
<div class="rhint">${this._t('decor.live_hint')}</div>
<label>${this._t('decor.live_entity')}</label>
<input class="namein" type="text" list="hp-dtext-ents" placeholder=${this._t('decor.live_entity_ph')}
.value=${d.entity || ''}
@input=${(e: Event) => (this._decorTextDialog = { ...d, entity: (e.target as HTMLInputElement).value })} />
<datalist id="hp-dtext-ents">
${Object.keys(this.hass?.states || {}).map((id) => html`<option value=${id}></option>`)}
</datalist>
${ent ? html`
<label>${this._t('decor.live_attr')}</label>
<input class="namein" type="text" list="hp-dtext-attrs" placeholder=${this._t('decor.live_attr_ph')}
.value=${d.attr || ''}
@input=${(e: Event) => (this._decorTextDialog = { ...d, attr: (e.target as HTMLInputElement).value })} />
<datalist id="hp-dtext-attrs">
${Object.keys(st?.attributes || {}).map((a) => html`<option value=${a}></option>`)}
</datalist>
<label>${this._t('decor.live_unit')}</label>
<input class="namein" type="text" .value=${d.unit || ''}
placeholder=${(d.attr || '').trim() ? '' : ownUnit}
@input=${(e: Event) => (this._decorTextDialog = { ...d, unit: (e.target as HTMLInputElement).value })} />
<label>${this._t('decor.live_preview')}</label>
<div class="dtpreview">${decorTextLines(preview).map((ln) => html`<div>${ln}</div>`)}</div>
` : nothing}
</div>
<div class="row">
<span class="spacer"></span>
@@ -6581,6 +7071,7 @@ class HouseplanCard extends LitElement {
grabbable (docs/BACKDROP.md §2). It exists only in the
backdrop editor, where rooms and devices are pointer-inert. */}
${this._renderBackdropFrame(view)}
${this._renderTextFrame(view)}
</svg>
${''/* docs/CANVAS.md §6: an icon is a percentage of the PLAN and
scales with it when you zoom — the behaviour the card always
+10 -4
View File
@@ -273,10 +273,16 @@
"decor.fill": "Fill",
"decor.text_title": "Text label",
"decor.text_label": "Text",
"decor.text_size": "Size",
"decor.size_s": "Small",
"decor.size_m": "Medium",
"decor.size_l": "Large",
"decor.text_hint": "A new line here is a new line on the plan; the block is centred. Drag a corner to resize it, the handle above it to turn it.",
"decor.text_hint_live": "{} is where the value lands. Without it the value is added at the end. A new line here is a new line on the plan.",
"decor.live_group": "Live value",
"decor.live_hint": "The label may show one entity's value — as Home Assistant reports it, with no rounding. A dead entity shows a dash.",
"decor.live_entity": "Entity",
"decor.live_entity_ph": "none — a plain label",
"decor.live_attr": "Attribute",
"decor.live_attr_ph": "the state",
"decor.live_unit": "Unit",
"decor.live_preview": "Preview",
"decor.backdrop": "Backdrop image",
"decor.backdrop_hint": "Drag the picture to move it, pull a corner to resize it. Shift — off the grid.",
"decor.backdrop_reset": "Reset the picture",
+10 -4
View File
@@ -273,10 +273,16 @@
"decor.fill": "Залить",
"decor.text_title": "Надпись",
"decor.text_label": "Текст",
"decor.text_size": "Размер",
"decor.size_s": "Мелкий",
"decor.size_m": "Средний",
"decor.size_l": "Крупный",
"decor.text_hint": "Перевод строки здесь — перевод строки на плане; блок выравнивается по центру. Тяните угол — меняет размер, ручка сверху — поворот.",
"decor.text_hint_live": "{} — место, куда встанет значение. Без него значение добавится в конец. Перевод строки здесь — перевод строки на плане.",
"decor.live_group": "Живое значение",
"decor.live_hint": "Надпись может показывать значение одной сущности — так, как его отдаёт Home Assistant, без округления. Мёртвая сущность — прочерк.",
"decor.live_entity": "Сущность",
"decor.live_entity_ph": "нет — обычная надпись",
"decor.live_attr": "Атрибут",
"decor.live_attr_ph": "состояние",
"decor.live_unit": "Единица",
"decor.live_preview": "Предпросмотр",
"decor.backdrop": "Картинка-подложка",
"decor.backdrop_hint": "Тяните картинку — двигается, тяните угол — меняет размер. Shift — мимо сетки.",
"decor.backdrop_reset": "Вернуть картинку",
+99
View File
@@ -910,6 +910,105 @@ export function floorsOf(hass: any): FloorInfo[] {
return list;
}
// ---------------- live text on a decor label (docs/LIVE-TEXT.md) -------------
/** What a dead sensor says. A label that vanishes with its entity is worse
* than one that admits it has no data. */
export const LIVE_TEXT_DASH = '—';
/** A caption is a caption: an attribute that turns out to be a 4 KB string
* must not become the plan's wallpaper. */
export const LIVE_TEXT_VALUE_MAX = 60;
/** The one placeholder. Not `{{ }}`: this is a substitution, not a template
* language — see docs/LIVE-TEXT.md for why that line is where it is. */
export const LIVE_TEXT_SLOT = '{}';
export interface LiveTextLink {
/** entity id whose value lands in the label; absent = a plain static label */
entity?: string | null;
/** attribute to read instead of the state */
attr?: string | null;
/** suffix; absent = the entity's own unit_of_measurement (state only) */
unit?: string | null;
}
/** One attribute/state value as text, or null when there is nothing to show. */
function liveRaw(raw: unknown): string | null {
if (raw === undefined || raw === null) return null;
if (Array.isArray(raw)) {
const s = raw.map((v) => (v === null || v === undefined ? '' : String(v))).join(', ');
return s ? s.slice(0, LIVE_TEXT_VALUE_MAX) : null;
}
if (typeof raw === 'object') return null; // a dict is not a caption
const s = String(raw);
return s === '' ? null : s.slice(0, LIVE_TEXT_VALUE_MAX);
}
/**
* The live value of a linked label, unit included — exactly as HA reports it.
* No rounding, no reformatting, no localised separators: rounding belongs to
* the sensor's `display_precision`, and duplicating it here would make two
* sources of truth (docs/LIVE-TEXT.md).
*
* Unit resolution: an explicit `unit` always wins. An empty one inherits the
* entity's `unit_of_measurement` ONLY when the STATE is being read — a
* `battery_level` attribute on a °C sensor must not come out as «73 °C».
*/
export function liveTextValue(hass: any, link: LiveTextLink | null | undefined): string {
const id = (link?.entity || '').trim();
if (!id) return '';
const st = hass?.states?.[id];
const state = st?.state;
if (!st || state === undefined || state === null || state === ''
|| state === 'unavailable' || state === 'unknown') return LIVE_TEXT_DASH;
const attr = (link?.attr || '').trim();
const val = liveRaw(attr ? st.attributes?.[attr] : state);
if (val === null) return LIVE_TEXT_DASH; // the attribute is not on this entity
const own = attr ? '' : String(st.attributes?.unit_of_measurement ?? '').trim();
const unit = String(link?.unit ?? '').trim() || own;
return unit ? `${val} ${unit}` : val;
}
/**
* The label as it must be painted: the template with its first `{}` replaced
* by the live value. A template without the placeholder gets the value
* appended after a space, so picking an entity and typing nothing sensible
* still shows something useful. Only the FIRST `{}` is substituted — one
* label, one value (docs/LIVE-TEXT.md «not a multi-entity widget»).
* Without an entity the text is returned byte-for-byte.
*/
export function liveText(text: string | null | undefined, link: LiveTextLink | null | undefined, hass: any): string {
const tpl = text ?? '';
if (!(link?.entity || '').trim()) return tpl;
const v = liveTextValue(hass, link);
const i = tpl.indexOf(LIVE_TEXT_SLOT);
if (i >= 0) return tpl.slice(0, i) + v + tpl.slice(i + LIVE_TEXT_SLOT.length);
return tpl ? `${tpl} ${v}` : v;
}
export const DECOR_TEXT_BASE = 20; // px at scale 1 — what 'm' has always been
export const DECOR_TEXT_SCALE_MIN = 0.15;
export const DECOR_TEXT_SCALE_MAX = 20;
/**
* The font multiplier of a decor text shape. `scale` is the field the corner
* handles write; the legacy `size` ('s'|'m'|'l') is read as the multiplier it
* used to render at (14/20/30 px against the base 20), so a label drawn
* before the handles existed comes back at exactly its old size without any
* migration. An explicit `scale` wins — it is the newer, finer statement of
* the same thing, and the first drag replaces `size` with it.
*/
export function decorTextScale(shape: { scale?: unknown; size?: unknown } | null | undefined): number {
const s = Number(shape?.scale);
if (Number.isFinite(s) && s > 0) return Math.min(DECOR_TEXT_SCALE_MAX, Math.max(DECOR_TEXT_SCALE_MIN, s));
const legacy: Record<string, number> = { s: 0.7, m: 1, l: 1.5 };
return legacy[String(shape?.size ?? '')] ?? 1;
}
/** The lines of a decor label. Explicit newlines only: the label never wraps
* by itself, so a caption cannot reflow (and jump) on a state change. */
export function decorTextLines(s: string | null | undefined): string[] {
return String(s ?? '').replace(/\r\n?/g, '\n').split('\n');
}
/** Substitute every occurrence of {name} placeholders in a template string. */
export function subst(s: string, vars?: Record<string, string | number>): string {
if (!vars) return s;
+54
View File
@@ -896,6 +896,15 @@ export const cardStyles = css`
.stage.mode-decor.dtool-ellipse .decorlayer .dshape,
.stage.mode-decor.dtool-text .decorlayer .dshape,
.stage.mode-decor.dtool-backdrop .decorlayer .dshape { pointer-events: none; }
/* ONE exception (owner, 2026-08-04): under the TEXT tool an existing LABEL
is a target again — pressing it opens its editor instead of starting a
new label on top of the old one. Only labels: a line or a rectangle
under the text tool stays inert, so the press reaches the stage and a
new label is created there. */
.stage.mode-decor.dtool-text .decorlayer .dshape.dtext {
pointer-events: visiblePainted;
cursor: text;
}
.decorlayer .dsel {
filter: drop-shadow(0 0 3px var(--hp-accent));
}
@@ -945,6 +954,51 @@ export const cardStyles = css`
transform: translate(-50%, -50%);
border: 1px solid var(--hp-accent);
}
/* the selected text block's frame — same chrome rules as the backdrop's:
the outline never takes a pointer, the handles always do, and they are
finger-sized because this is dragged on a tablet too */
.dtframe .dtbox {
fill: none;
stroke: var(--hp-accent);
stroke-width: 1.5;
stroke-dasharray: 7 5;
vector-effect: non-scaling-stroke;
pointer-events: none;
opacity: 0.85;
}
.dtframe .dtstem {
stroke: var(--hp-accent);
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
pointer-events: none;
opacity: 0.85;
}
.dtframe .dthandle {
fill: var(--hp-accent);
stroke: #fff;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
pointer-events: all;
touch-action: none;
}
.dtframe .dt-nwse { cursor: nwse-resize; }
.dtframe .dt-nesw { cursor: nesw-resize; }
.dtframe .dtrot { cursor: grab; }
.dtarea {
resize: vertical;
min-height: 3.4em;
font: inherit;
line-height: 1.35;
}
.dtpreview {
padding: 6px 8px;
border-radius: 8px;
background: rgba(127, 127, 127, 0.12);
text-align: center;
white-space: pre-wrap;
word-break: break-word;
font-weight: 600;
}
.stage.mode-decor.dtool-line, .stage.mode-decor.dtool-rect,
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
cursor: crosshair;
+112 -1
View File
@@ -15,7 +15,9 @@ import {
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
interiorPoint,
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS, coverService, coverMoving, coverEntityOf } from '../test-build/logic.js';
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS, coverService, coverMoving, coverEntityOf,
liveText, liveTextValue, decorTextScale, decorTextLines,
LIVE_TEXT_DASH, LIVE_TEXT_VALUE_MAX, DECOR_TEXT_SCALE_MIN, DECOR_TEXT_SCALE_MAX } from '../test-build/logic.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
} from '../test-build/rules.js';
@@ -1190,3 +1192,112 @@ test('openingShoulders: angled wall measures along the wall direction', () => {
assert.equal(off.centered, false);
assert.ok(Math.abs(off.wallCenter[0] - 150) < 1e-9 && Math.abs(off.wallCenter[1] - 200) < 1e-9);
});
// ---------------- live text on a decor label (docs/LIVE-TEXT.md) ------------
const hassLive = {
states: {
'sensor.tank': { state: '68', attributes: { unit_of_measurement: '%' } },
'sensor.plain': { state: '17.4', attributes: {} },
'climate.hall': { state: 'heat', attributes: { current_temperature: 21.5, unit_of_measurement: '°C', preset_modes: ['home', 'away'] } },
'sensor.dead': { state: 'unavailable', attributes: { unit_of_measurement: '%' } },
'sensor.unknown': { state: 'unknown', attributes: {} },
'sensor.blob': { state: 'ok', attributes: { payload: { a: 1 }, zero: 0, no: false, empty: '' } },
},
};
test('liveText: no entity = the text is untouched, byte for byte', () => {
assert.equal(liveText('Кухня', null, hassLive), 'Кухня');
assert.equal(liveText('Бак {}', {}, hassLive), 'Бак {}');
assert.equal(liveText('Бак {}', { entity: ' ' }, hassLive), 'Бак {}');
assert.equal(liveText('', null, hassLive), '');
});
test('liveText: the placeholder is where the value lands', () => {
assert.equal(liveText('Бак {}', { entity: 'sensor.tank' }, hassLive), 'Бак 68 %');
assert.equal(liveText('{} в баке', { entity: 'sensor.tank' }, hassLive), '68 % в баке');
});
test('liveText: no placeholder = the value is appended after a space', () => {
assert.equal(liveText('Бак', { entity: 'sensor.tank' }, hassLive), 'Бак 68 %');
assert.equal(liveText('', { entity: 'sensor.tank' }, hassLive), '68 %', 'empty template = the bare value, no leading space');
});
test('liveText: only the FIRST placeholder is replaced — one label, one value', () => {
assert.equal(liveText('{} и {}', { entity: 'sensor.tank' }, hassLive), '68 % и {}');
});
test('liveText: the placeholder may sit on any line of a multi-line label', () => {
assert.equal(liveText('Бак\n{}', { entity: 'sensor.tank' }, hassLive), 'Бак\n68 %');
});
test('liveTextValue: unit comes from the entity, an explicit one wins', () => {
assert.equal(liveTextValue(hassLive, { entity: 'sensor.tank' }), '68 %');
assert.equal(liveTextValue(hassLive, { entity: 'sensor.tank', unit: 'проц.' }), '68 проц.');
assert.equal(liveTextValue(hassLive, { entity: 'sensor.tank', unit: ' ' }), '68 %', 'blank = inherit');
assert.equal(liveTextValue(hassLive, { entity: 'sensor.plain' }), '17.4', 'no unit on the entity, none added');
});
test('liveTextValue: an attribute never inherits the STATE unit', () => {
assert.equal(liveTextValue(hassLive, { entity: 'climate.hall', attr: 'current_temperature' }), '21.5',
'the entity °C describes the state, not the attribute');
assert.equal(liveTextValue(hassLive, { entity: 'climate.hall', attr: 'current_temperature', unit: '°C' }), '21.5 °C');
assert.equal(liveTextValue(hassLive, { entity: 'climate.hall' }), 'heat °C', 'the state keeps the entity unit');
});
test('liveTextValue: values are shown as HA reports them — no rounding', () => {
const hass = { states: { 'sensor.x': { state: '17.40000', attributes: { unit_of_measurement: '°C' } } } };
assert.equal(liveTextValue(hass, { entity: 'sensor.x' }), '17.40000 °C');
});
test('liveTextValue: a dead or missing entity is a dash, and the dash has no unit', () => {
assert.equal(liveTextValue(hassLive, { entity: 'sensor.dead' }), LIVE_TEXT_DASH);
assert.equal(liveTextValue(hassLive, { entity: 'sensor.unknown' }), LIVE_TEXT_DASH);
assert.equal(liveTextValue(hassLive, { entity: 'sensor.nope' }), LIVE_TEXT_DASH);
assert.equal(liveTextValue({}, { entity: 'sensor.tank' }), LIVE_TEXT_DASH);
assert.equal(liveText('Бак {}', { entity: 'sensor.dead' }, hassLive), 'Бак —', 'the rest of the template stays');
});
test('liveTextValue: a missing attribute on a live entity is a dash too', () => {
assert.equal(liveTextValue(hassLive, { entity: 'climate.hall', attr: 'nope' }), LIVE_TEXT_DASH);
assert.equal(liveTextValue(hassLive, { entity: 'sensor.blob', attr: 'payload' }), LIVE_TEXT_DASH, 'a dict is not a caption');
assert.equal(liveTextValue(hassLive, { entity: 'sensor.blob', attr: 'empty' }), LIVE_TEXT_DASH);
assert.equal(liveTextValue(hassLive, { entity: 'sensor.blob', attr: 'zero' }), '0', '0 is a value, not an absence');
assert.equal(liveTextValue(hassLive, { entity: 'sensor.blob', attr: 'no' }), 'false');
assert.equal(liveTextValue(hassLive, { entity: 'climate.hall', attr: 'preset_modes' }), 'home, away');
});
test('liveTextValue: a runaway value is clipped, so a caption stays a caption', () => {
const hass = { states: { 'sensor.big': { state: 'x'.repeat(500), attributes: {} } } };
const v = liveTextValue(hass, { entity: 'sensor.big' });
assert.equal(v.length, LIVE_TEXT_VALUE_MAX);
assert.ok(liveText('Log: {}', { entity: 'sensor.big' }, hass).length <= 'Log: '.length + LIVE_TEXT_VALUE_MAX);
});
test('decorTextScale: legacy size renders exactly as it used to', () => {
assert.equal(decorTextScale({ size: 's' }), 0.7, '14px against the base 20');
assert.equal(decorTextScale({ size: 'm' }), 1);
assert.equal(decorTextScale({ size: 'l' }), 1.5, '30px against the base 20');
assert.equal(decorTextScale({}), 1, 'a shape with neither is the medium it has always been');
assert.equal(decorTextScale(null), 1);
});
test('decorTextScale: an explicit scale wins over the legacy size and is bounded', () => {
assert.equal(decorTextScale({ size: 's', scale: 2 }), 2);
assert.equal(decorTextScale({ scale: 0 }), 1, '0 is not a scale — fall back');
assert.equal(decorTextScale({ scale: -3 }), 1);
assert.equal(decorTextScale({ scale: 'x' }), 1);
assert.equal(decorTextScale({ scale: 1e9 }), DECOR_TEXT_SCALE_MAX);
assert.equal(decorTextScale({ scale: 1e-9 }), DECOR_TEXT_SCALE_MIN);
});
test('decorTextLines: explicit newlines only, never an automatic wrap', () => {
assert.deepEqual(decorTextLines('a\nb'), ['a', 'b']);
assert.deepEqual(decorTextLines('a\r\nb'), ['a', 'b'], 'CRLF from a pasted text');
assert.deepEqual(decorTextLines('one'), ['one']);
assert.deepEqual(decorTextLines(''), ['']);
assert.deepEqual(decorTextLines('a\n\nb'), ['a', '', 'b'], 'a blank line is a line');
const long = 'x'.repeat(300);
assert.deepEqual(decorTextLines(long), [long], '300 chars stay one line — no auto wrap');
});
+70
View File
@@ -1135,3 +1135,73 @@ class TestVacuum:
v.MARKER_SCHEMA(_marker(vacuum={"trail_mode": ok}))
with pytest.raises(Exception):
v.MARKER_SCHEMA(_marker(vacuum={"trail_mode": "sometimes"}))
def test_decor_text_live_fields():
"""docs/LIVE-TEXT.md: a text label may carry an entity, an attribute and a
unit. All three optional and BOUNDED; junk is refused; a label without them
stays valid, so no plan needs a migration."""
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
txt = {"id": "d", "kind": "text", "x": 0.5, "y": 0.5, "text": "Бак {}"}
def cfg(extra):
return v.CONFIG_SCHEMA({"spaces": [{**base, "decor": [{**txt, **extra}]}]})
# --- a plain label is untouched by the new fields ---------------------
assert cfg({})["spaces"][0]["decor"][0] == txt
# --- accepted ---------------------------------------------------------
out = cfg({"entity": "sensor.water_tank", "attr": "battery_level", "unit": "%"})
shape = out["spaces"][0]["decor"][0]
assert shape["entity"] == "sensor.water_tank"
assert shape["attr"] == "battery_level"
assert shape["unit"] == "%"
# None is how the dialog clears a field
assert cfg({"entity": None, "attr": None, "unit": None})
assert cfg({"entity": "binary_sensor.a_1", "attr": "x", "unit": "°C"})
# --- refused ----------------------------------------------------------
for bad in ("sensor", "sensor.", ".tank", "Sensor.Tank", "sensor.tank; drop",
"sensor." + "x" * 300, 5, True, ["sensor.a"]):
with pytest.raises(vol.Invalid):
cfg({"entity": bad})
with pytest.raises(vol.Invalid):
cfg({"attr": "a" * (v.MAX_DECOR_ATTR + 1)})
with pytest.raises(vol.Invalid):
cfg({"unit": "u" * (v.MAX_DECOR_UNIT + 1)})
with pytest.raises(vol.Invalid):
cfg({"attr": 7})
with pytest.raises(vol.Invalid):
cfg({"unit": {"a": 1}})
# the template itself keeps its own bound, placeholder or not
assert cfg({"text": "x" * v.MAX_DECOR_TEXT})
with pytest.raises(vol.Invalid):
cfg({"text": "x" * (v.MAX_DECOR_TEXT + 1)})
def test_decor_text_block_scale_and_angle():
"""The dialog no longer offers a font size — the block is scaled by its
corners and rotated by its handle. Both fields optional and bounded; the
legacy `size` is still accepted (and still bounded) so a label drawn before
the handles renders exactly as it did."""
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
txt = {"id": "d", "kind": "text", "x": 0.5, "y": 0.5, "text": "Porch"}
def cfg(extra):
return v.CONFIG_SCHEMA({"spaces": [{**base, "decor": [{**txt, **extra}]}]})
assert cfg({"scale": 1})["spaces"][0]["decor"][0]["scale"] == 1.0
assert cfg({"scale": v.DECOR_TEXT_SCALE_MIN})
assert cfg({"scale": v.DECOR_TEXT_SCALE_MAX})
assert cfg({"angle": 0}) and cfg({"angle": -360}) and cfg({"angle": 360})
assert cfg({"angle": 45.5})["spaces"][0]["decor"][0]["angle"] == 45.5
for bad in (0, -1, v.DECOR_TEXT_SCALE_MAX + 1, float("nan"), float("inf")):
with pytest.raises(vol.Invalid):
cfg({"scale": bad})
for bad in (361, -361, float("nan")):
with pytest.raises(vol.Invalid):
cfg({"angle": bad})
# legacy: still valid, still only the three known values
assert cfg({"size": "s"}) and cfg({"size": "m"}) and cfg({"size": "l"})
with pytest.raises(vol.Invalid):
cfg({"size": "xxl"})
# a multi-line label round-trips with its newlines intact
assert cfg({"text": "Гараж\nпод ключ"})["spaces"][0]["decor"][0]["text"] == "Гараж\nпод ключ"