This commit is contained in:
Matysh
2026-08-01 12:40:39 +03:00
22 changed files with 836 additions and 94 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.54.2"
VERSION = "1.54.3"
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.54.2"
"version": "1.54.3"
}
+31
View File
@@ -0,0 +1,31 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
await page.evaluate(async () => {
const c = window.__card;
// the flash is keyed to a WITNESSED off→on transition (owner's rule
// 2026-08-01: разовая вспышка в момент обнаружения) — cycle through off
const set = async (state) => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state,
attributes: { friendly_name: 'Motion', device_class: 'motion', linkquality: 64 } } } };
await c.updateComplete;
};
await set('off');
await set('on');
// freeze the pulse at a visible frame for a deterministic capture
const st = document.createElement('style');
st.textContent = '.dev.senseflash::after{animation-delay:-0.4s;animation-play-state:paused;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
});
await page.waitForTimeout(300);
const box = await page.evaluate(() => {
const c = window.__card;
const el = (c.shadowRoot || c.renderRoot).querySelector('.dev.senseflash');
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const pad = 70;
await page.screenshot({ path: process.argv[2] || '/tmp/motion_sense.png',
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.w + pad * 2, height: box.h + pad * 2 } });
await browser.close();
console.log('shot ok');
+57 -1
View File
@@ -35,4 +35,60 @@ const out = await page.evaluate(async () => {
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
return o;
});
await finish(browser, checkAll(out));
checkAll(out);
// Тот же класс бага для СПУТНИКОВ значка (репорт владельца, 2026-08-01):
// бейдж значения (display:value) и temp-плашка считались от базового
// --icon-size и оставались маленькими при size=2. Всё, что визуально
// принадлежит устройству, обязано расти вместе с --dev-scale.
const out2 = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const devEl = () => {
const idx = c._devices.filter((x) => x.space === c._space).findIndex((x) => x.id === 'd_temp');
return sr().querySelectorAll('.dev')[idx];
};
const setMarker = async (extra) => {
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== 'd_temp');
c._serverCfg.markers.push({ id: 'd_temp', binding: 'device:d_temp', ...extra });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 80));
};
const fs = (el) => parseFloat(getComputedStyle(el).fontSize);
// базовый --icon-size в px: ширина немасштабированного бейджа минус рамка 1px×2
const plain = [...sr().querySelectorAll('.dev')].find(
(e) => !e.classList.contains('ghost') && !e.classList.contains('valonly') && !(e.getAttribute('style') || '').includes('--dev-scale'));
const iconPx = plain.getBoundingClientRect().width - 2;
// --- бейдж значения (valonly) ---
await setMarker({ display: 'value', size: 1 });
const v1 = devEl().getBoundingClientRect();
const vf1 = fs(devEl().querySelector('.valtext'));
// при size=1 дефолт не изменился: шрифт ровно прежние 0.45 * icon-size
o.valDefaultKept = Math.abs(vf1 - iconPx * 0.45) < 0.75;
await setMarker({ display: 'value', size: 2 });
const v2 = devEl().getBoundingClientRect();
const vf2 = fs(devEl().querySelector('.valtext'));
o.valFontScales = vf2 / vf1 > 1.8 && vf2 / vf1 < 2.2;
o.valPlateScales = (v2.height - 2) / (v1.height - 2) > 1.8 && (v2.height - 2) / (v1.height - 2) < 2.2
&& v2.width / v1.width > 1.6 && v2.width / v1.width < 2.4;
// --- temp-плашка (.tval) рядом со значком ---
await setMarker({ size: 1 });
const t1 = devEl().querySelector('.tval').getBoundingClientRect();
const tf1 = fs(devEl().querySelector('.tval'));
// дефолт: line-height 0.68 * icon-size + рамка 1px×2
o.tvalDefaultKept = Math.abs(t1.height - (iconPx * 0.68 + 2)) < 1;
await setMarker({ size: 2 });
const t2 = devEl().querySelector('.tval').getBoundingClientRect();
const tf2 = fs(devEl().querySelector('.tval'));
o.tvalScales = (t2.height - 2) / (t1.height - 2) > 1.8 && (t2.height - 2) / (t1.height - 2) < 2.2
&& tf2 / tf1 > 1.8 && tf2 / tf1 < 2.2;
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== 'd_temp');
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
return o;
});
await finish(browser, checkAll(out2));
+91
View File
@@ -0,0 +1,91 @@
// Правило владельца (2026-08-01, вариант «б»): «движение = разовая вспышка
// в момент обнаружения; cool-down не пульсирует; присутствие = статичное
// кольцо пока обитаемо». Датчик ДВИЖЕНИЯ (device_class motion) при переходе
// off→on получает класс 'senseflash' — короткая вспышка кольца (hp-sense,
// 3 удара, ~3.3 c), после чего класс снимается, ДАЖЕ если сущность всё ещё
// висит в on (это cool-down датчика, а не движение). Новый цикл off→on —
// новая вспышка. Датчики ПРИСУТСТВИЯ (occupancy/presence) при on несут
// 'sensehold' — статичное жёлтое кольцо без анимации (opacity 0.4). Жёлтая
// заливка .dev.on в обоих случаях не участвует; ghost — без кольца
// (призрак — конфигурация, не статус).
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 setMotion = async (state, device_class) => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state,
attributes: { friendly_name: 'Motion', device_class, linkquality: 64 } } } };
await c.updateComplete;
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// --- motion off→on: вспышка senseflash, 3 удара, подложка НЕ жёлтая -----
await setMotion('off', 'motion');
out.offNoFlash = sr().querySelectorAll('.dev.senseflash, .dev.sensehold').length === 0;
await setMotion('on', 'motion');
const flashEl = sr().querySelector('.dev.senseflash');
out.motionTripFlashes = !!flashEl;
out.flashIsNotOn = !!flashEl && !flashEl.classList.contains('on') && !flashEl.classList.contains('open');
const after = flashEl ? getComputedStyle(flashEl, '::after') : null;
out.flashAnimated = !!after && after.animationName === 'hp-sense';
out.flashFinite = !!after && after.animationIterationCount === '3'; // не infinite!
// подложка нейтральная — как у обычного .dev, не как у .dev.on
const neutral = [...sr().querySelectorAll('.dev')].find((e) =>
!e.classList.contains('senseflash') && !e.classList.contains('sensehold')
&& !e.classList.contains('on') && !e.classList.contains('open')
&& !e.classList.contains('alarm') && !e.classList.contains('unavail') && !e.classList.contains('valonly'));
const bg = flashEl ? getComputedStyle(flashEl).backgroundColor : '';
out.flashBgNeutral = !!neutral && bg === getComputedStyle(neutral).backgroundColor;
out.flashBgNotYellow = bg !== '' && bg !== 'rgb(255, 212, 92)'; // --hp-on
// --- КЛЮЧЕВОЙ ассерт: вспышка гаснет, хотя state всё ещё on -------------
// (cool-down датчика — не движение: класс обязан уйти сам по таймеру)
await sleep(3600);
await c.updateComplete;
out.stateStillOn = c.hass.states['binary_sensor.hall_motion'].state === 'on';
out.flashEndsWhileOn = sr().querySelectorAll('.dev.senseflash').length === 0;
// --- повторный цикл off→on: новая вспышка --------------------------------
await setMotion('off', 'motion');
await setMotion('on', 'motion');
out.retripFlashesAgain = sr().querySelectorAll('.dev.senseflash').length === 1;
// --- occupancy: статичное кольцо sensehold, без анимации ----------------
await setMotion('on', 'occupancy');
const holdEl = sr().querySelector('.dev.sensehold');
out.occupancyHolds = !!holdEl;
const hAfter = holdEl ? getComputedStyle(holdEl, '::after') : null;
out.holdNotAnimated = !!hAfter && hAfter.animationName === 'none';
out.holdRingFaint = !!hAfter && Math.abs(parseFloat(hAfter.opacity) - 0.4) < 0.01;
out.holdBgNeutral = !!holdEl && !!neutral
&& getComputedStyle(holdEl).backgroundColor === getComputedStyle(neutral).backgroundColor;
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('senseflash');
await setMotion('off', 'occupancy');
out.occupancyOffClears = sr().querySelectorAll('.dev.sensehold').length === 0;
// --- presence аналогично -------------------------------------------------
await setMotion('on', 'presence');
out.presenceHolds = sr().querySelectorAll('.dev.sensehold').length === 1;
// и это именно sense-состояние, не тревога и не «открыто»
out.noAlarmNoOpen = !sr().querySelector('.dev.sensehold.alarm') && !sr().querySelector('.dev.sensehold.open');
// --- ghost не рисует ничего (призрак — конфигурация, не статус) ---------
await setMotion('off', 'motion');
await setMotion('on', 'motion');
const d = c._devices.find((x) => x.entities.includes('binary_sensor.hall_motion'));
c._serverCfg.markers = c._serverCfg.markers || [];
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
out.hiddenNotInView = sr().querySelectorAll('.dev.senseflash, .dev.sensehold').length === 0;
c._showHidden = true; c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
const ghost = sr().querySelector('.dev.ghost');
out.ghostNoSense = !!ghost && !ghost.classList.contains('senseflash') && !ghost.classList.contains('sensehold')
&& getComputedStyle(ghost, '::after').animationName !== 'hp-sense';
return out;
});
checkAll(res, {});
await finish(browser, res);
+96
View File
@@ -48,6 +48,102 @@ out.floorIsHalf = await page.evaluate(() => { window.__card._resetZoom(); const
c._applyView(0.1); return c._zoom; }) === 0.4; // clamped at the floor
await page.evaluate(() => window.__card._resetZoom());
// -- editor zoom is a working tool, not the viewing intent ----------------
// view 1.6 → devices editor 2.5 → back to view: the pre-editor viewport
// (zoom AND center) comes back; the editor keeps its own zoom while open.
out.editorZoomNotSaved = await page.evaluate(async () => {
const c = window.__card;
const raf2 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const center = () => { const v = c._view; return [v.x + v.w / 2, v.y + v.h / 2]; };
c._setMode('view');
c._resetZoom();
c._zoomAt(10, 10, 1.6); c._saveZoom(); // off-center on purpose
const want = { zoom: c._zoom, c: center() };
c._setMode('devices'); await raf2();
c._zoomAt(10, 10, 2.5); c._saveZoom(); // off-center too: the center must not leak either
const editorZoomFree = Math.abs(c._zoom - 2.5) < 0.01; // zooming inside stays
c._setMode('view'); await raf2();
const got = { zoom: c._zoom, c: center() };
const ls = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}');
return {
editorZoomFree,
zoomRestored: Math.abs(got.zoom - want.zoom) < 0.01,
centerRestored: Math.hypot(got.c[0] - want.c[0], got.c[1] - want.c[1]) < 0.02,
lsRestored: Math.abs((ls[c._space] || 1) - want.zoom) < 0.01,
};
});
out.editorZoomFree = out.editorZoomNotSaved.editorZoomFree;
out.viewZoomRestored = out.editorZoomNotSaved.zoomRestored;
out.viewCenterRestored = out.editorZoomNotSaved.centerRestored;
out.viewZoomBackInLs = out.editorZoomNotSaved.lsRestored;
delete out.editorZoomNotSaved;
// -- editor entered and left without touching zoom: no jump ---------------
out.untouchedEditorNoJump = await page.evaluate(async () => {
const c = window.__card;
const raf2 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
c._setMode('view');
c._resetZoom();
c._zoomAt(30, 30, 1.6); c._saveZoom();
const before = { ...c._view };
c._setMode('plan'); await raf2();
c._setMode('view'); await raf2();
const v = c._view;
return Math.abs(c._zoom - 1.6) < 0.01
&& Math.abs(v.x - before.x) < 0.005 && Math.abs(v.y - before.y) < 0.005
&& Math.abs(v.w - before.w) < 0.005 && Math.abs(v.h - before.h) < 0.005;
});
// -- the view zoom still survives a space switch, per space ---------------
out.viewZoomSurvivesSpaceSwitch = await page.evaluate(async () => {
const c = window.__card;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
c._setMode('view');
c._resetZoom(); c._applyView(1.7); c._saveZoom();
c._slideTo('garden', 'left'); await wait(350);
const gardenGotOwnZoom = Math.abs(c._zoom - (JSON.parse(
localStorage.getItem('houseplan_card_zoom_v1') || '{}').garden || 1)) < 0.01;
c._slideTo('f1', 'right'); await wait(350);
return gardenGotOwnZoom && Math.abs(c._zoom - 1.7) < 0.01;
});
await page.evaluate(() => window.__card._resetZoom());
// -- owner's dacha regression: editor 500% must never reach the per-space
// view store. View zoom set on BOTH floors, editor cranked to 5.0, back to
// view — and the floor tab is clicked in the SAME tick (on the tablet the
// exit re-render janks, so the click runs before the restore rAF and the
// old fix-up save was skipped). Two switches later the stale 5.0 came back.
out.editorZoomNeverInSpaceStore = await page.evaluate(async () => {
const c = window.__card;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const raf2 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
c._setMode('view');
c._resetZoom(); c._zoomAt(10, 10, 1.6); c._saveZoom(); // floor 1 view zoom
c._slideTo('garden', 'left'); await wait(350);
c._resetZoom(); c._zoomAt(10, 10, 1.4); c._saveZoom(); // floor 2 view zoom
c._setMode('devices'); await raf2();
c._zoomAt(10, 10, 5.0); c._saveZoom(); // 500% — a working tool
const lsInEditor = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}');
c._setMode('view');
c._slideTo('f1', 'right'); await wait(350); // same tick as the exit — beats the rAF
const firstSwitch = Math.abs(c._zoom - 1.6) < 0.01;
c._slideTo('garden', 'left'); await wait(350);
const secondSwitch = Math.abs(c._zoom - 1.4) < 0.01; // used to come back as 5.0
const ls = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}');
c._slideTo('f1', 'right'); await wait(350); c._resetZoom(); // leave the stage as the next tests expect
return {
editorNotPersisted: Math.abs((lsInEditor.garden || 1) - 1.4) < 0.01,
firstSwitch,
secondSwitch,
lsClean: Math.abs((ls.garden || 1) - 1.4) < 0.01 && Math.abs((ls.f1 || 1) - 1.6) < 0.01,
};
});
out.editorZoomStaysOutOfLs = out.editorZoomNeverInSpaceStore.editorNotPersisted;
out.ownerFirstSwitchOk = out.editorZoomNeverInSpaceStore.firstSwitch;
out.ownerSecondSwitchOk = out.editorZoomNeverInSpaceStore.secondSwitch;
out.ownerLsClean = out.editorZoomNeverInSpaceStore.lsClean;
delete out.editorZoomNeverInSpaceStore;
// -- devices outside rooms stretch the default frame ---------------------
out.devicesStretchFrame = await page.evaluate(() => {
const c = window.__card;
File diff suppressed because one or more lines are too long
+16
View File
@@ -11,3 +11,19 @@ of the shipped integration.
(template LQI sensors, alarm helpers, the smoke automation) lives in the
seeds on the stand host — see `docs/TESTING-DEMO.md` and the memory note
`houseplan-demo-stand`.
- `demo_guard/` — stand-only guard (2026-07-31). Visitors log in as an
administrator (the card editor is gated on `is_admin`) and kept restarting
HA from the UI, which looked like the stand crashing between hourly resets.
The component re-registers `homeassistant.restart`/`homeassistant.stop` as
no-ops after startup. Deployed to `custom_components/demo_guard` in
seed-demo only (the dev stand sits behind basic auth) + `demo_guard:` in
configuration.yaml.
- `www/stand-reset-timer.js` — console-only countdown to the next hourly
reset (`hp-reset.timer`, every hour at :00). Served as
`/local/stand-reset-timer.js` from `seed-demo/www/`, wired through
`frontend: extra_module_url`. Logs a styled `console.info` every minute,
switching to `console.warn` for the last 5 minutes. `www/stand-dev-info.js`
is the dev-stand counterpart: a single `console.info` saying the dev stand
only resets on deploy.
+46
View File
@@ -0,0 +1,46 @@
"""Stand-only guard: keep visitor-admins from killing the public demo.
Visitors log in as `demo`, and that user HAS to be an administrator — the
House Plan card gates its editor on `user.is_admin`, and demonstrating the
editor is the whole point of the stand. But an administrator can also call
`homeassistant.restart` / `homeassistant.stop`, and visitors do exactly that
(seen live 2026-07-31: HA exited with the restart code 100 minutes after a
scheduled reset, right as a visitor session from an external IP was active —
that is what looked like «стенд перезагружается чаще, чем раз в час»).
Once startup finishes this component re-registers both services as no-ops:
the UI buttons stay, pressing them only leaves a WARNING in the log. The
hourly stand reset is NOT affected — hp-reset.timer restarts the docker
container from the outside.
"""
from __future__ import annotations
import logging
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
DOMAIN = "demo_guard"
BLOCKED = ("restart", "stop")
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def _blocked(call: ServiceCall) -> None:
_LOGGER.warning(
"демо-стенд: homeassistant.%s отключён для посетителей "
"(стенд и так сбрасывается сам, ежечасно в :00)",
call.service,
)
@callback
def _neuter(_event) -> None:
# After EVENT_HOMEASSISTANT_STARTED every core service is in place;
# registering the same domain/name again replaces the handler.
for service in BLOCKED:
hass.services.async_register("homeassistant", service, _blocked)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _neuter)
return True
+10
View File
@@ -0,0 +1,10 @@
{
"domain": "demo_guard",
"name": "Demo Stand Guard",
"codeowners": [],
"dependencies": [],
"documentation": "https://github.com/Matysh/houseplan-card/tree/dev/demo/stand/demo_guard",
"iot_class": "calculated",
"requirements": [],
"version": "1.0.0"
}
+5
View File
@@ -0,0 +1,5 @@
# demo_guard re-registers EXISTING homeassistant.restart / homeassistant.stop
# as no-ops on the public demo stand (visitors are admins by necessity — the
# card's editor requires is_admin — and kept restarting HA). It defines no
# services of its own; this file exists because hassfest requires it for any
# integration that calls async_register.
+7
View File
@@ -0,0 +1,7 @@
// Dev-стенд House Plan: ежечасного сброса НЕТ. Контейнер перезапускается
// только при новом коммите в ветку dev (hp-update-dev.timer, проверка раз
// в 10 минут). Одно информационное сообщение при загрузке, без таймера.
console.info(
"%c[dev-стенд] сброс только при деплое: перезапуск при новом коммите в dev (проверка раз в 10 мин)",
"background:#5d4037;color:#fff;padding:2px 6px;border-radius:3px"
);
+31
View File
@@ -0,0 +1,31 @@
// Демо-стенд House Plan: таймер до ближайшего сброса — пишет ТОЛЬКО в консоль
// DevTools, никакого UI. Стенд сбрасывается systemd-таймером hp-reset.timer
// ежечасно в :00 (RandomizedDelaySec=0; сам перезапуск занимает ~15 секунд,
// отсюда «~» в сообщении).
(() => {
"use strict";
const INFO = "background:#03589c;color:#fff;padding:2px 6px;border-radius:3px";
const WARN = "background:#b71c1c;color:#fff;padding:2px 6px;border-radius:3px;font-weight:bold";
const tick = () => {
const now = new Date();
const next = new Date(now);
next.setMinutes(60, 0, 0); // ближайший :00
const left = Math.max(1, Math.ceil((next - now) / 60000));
if (left <= 5) {
console.warn(
`%c[демо-стенд] до сброса ~${left} мин — несохранённые эксперименты пропадут (сброс ежечасно в :00)`,
WARN
);
} else {
console.info(`%c[демо-стенд] до сброса ~${left} мин (сброс ежечасно в :00)`, INFO);
}
};
tick();
// выравниваемся по границе минуты, дальше — раз в минуту
setTimeout(() => {
tick();
setInterval(tick, 60000);
}, (60 - new Date().getSeconds()) * 1000);
})();
+71 -22
View File
File diff suppressed because one or more lines are too long
+28
View File
@@ -1,5 +1,33 @@
# Changelog
## v1.54.3 — 2026-08-01
Patch release: three quality-of-life rounds — icon satellites now follow
the per-device size multiplier, editor zoom stopped leaking into view
mode, and motion sensors got a visual language of their own.
- **The per-device size multiplier now scales the icon's satellites
too.** Reported by an owner with a screenshot: shrinking a device left
the value badge, the temperature/humidity plates, the LQI label, the
«new» dot and the alarm ring at full size, towering over the tiny
icon. Every satellite now follows the multiplier along with the icon.
- **Editor zoom is a working tool, not a saved setting.** Zooming to
500% to place a marker precisely and then closing the editor used to
drop you into a 500% view mode. Leaving any editor now restores the
viewport you had in view mode before you started editing.
- **Fix: editor zoom could still resurrect when switching floors.** The
editor viewport raced the per-floor viewport store and won: switch
floors while editing and the 500% was written down as that floor's
saved view, greeting you on the next visit (steps to reproduce came
from the owner). Editor zoom is never written to the store anymore.
- **Tripped motion and presence sensors signal with a yellow ring.** The
rule stays «fill = on»: motion is a one-shot flash — three pulses at
the moment of detection, with no pulsing during the cool-down tail —
while occupancy/presence hold a static ring for as long as they report
someone present.
- Stand only: a `services.yaml` for the demo stand's `demo_guard` to
keep hassfest green; nothing of it ships in the integration.
## v1.54.2 — 2026-07-31
Patch release: the one remaining finding of the v1.54.1 re-audit
+31
View File
@@ -6,6 +6,37 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.54.3 — 2026-08-01
Патч-релиз: три раунда качества жизни — спутники значка теперь слушаются
пер-девайсного множителя размера, редакторский зум перестал протекать в
режим просмотра, а датчики движения получили собственный визуальный язык.
- **Пер-девайсный множитель размера теперь масштабирует и спутники
значка.** Репорт владельца со скриншотом: у уменьшенного устройства
бейдж значения, плашки температуры и влажности, подпись LQI, точка
«new» и кольцо тревоги оставались полноразмерными и нависали над
крошечным значком. Теперь все спутники следуют за множителем вместе со
значком.
- **Редакторский зум — рабочий инструмент, а не сохранённая настройка.**
Приблизить до 500%, чтобы точно поставить маркер, и закрыть редактор
раньше означало оказаться в режиме просмотра на тех же 500%. Выход из
любого редактора теперь восстанавливает тот вид, который был в режиме
просмотра до начала правок.
- **Исправлено: редакторский зум всё ещё «воскресал» при переключении
этажей.** Редакторский вьюпорт гонялся с пер-этажным хранилищем вида и
побеждал: переключите этаж во время правок — и 500% записывались как
сохранённый вид этого этажа и встречали вас в следующий раз (шаги
воспроизведения — от владельца). Теперь редакторский зум в хранилище
не пишется никогда.
- **Сработавшие датчики движения и присутствия сигнализируют жёлтым
кольцом.** Правило прежнее — «подложка = включено»: движение — разовая
вспышка, три удара в момент обнаружения, без пульсации на хвосте
cool-down; occupancy/presence держат статичное кольцо, пока
докладывают о присутствии.
- Только для стенда: `services.yaml` для стендового `demo_guard`, чтобы
hassfest оставался зелёным; в саму интеграцию это не попадает.
## v1.54.2 — 2026-07-31
Патч-релиз: единственная оставшаяся находка повторного аудита v1.54.1
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State |
|---|---|
| Version | **v1.54.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.54.3** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| 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 (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| 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) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.54.0** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.54.3** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.54.2",
"version": "1.54.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.54.2",
"version": "1.54.3",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.54.2",
"version": "1.54.3",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+101 -3
View File
@@ -42,13 +42,17 @@ import { cardStyles } from './styles';
import { fitInSquare, contentBounds, spaceModels } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.54.2';
const CARD_VERSION = '1.54.3';
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';
const LS_NAV = 'houseplan_card_nav_v1'; // last space + editor mode (owner: restore where you were)
const LS_KIOSK = 'houseplan_card_kiosk_v1'; // per-SCREEN size multipliers (each wall tablet differs)
const NORM_W = 1000; // side of the render space — the canvas is square (v1.48.0)
/** motion flash window: 3 beats of the hp-sense ring (3 × 1.1s in styles.ts).
Owner's rule (2026-08-01): «движение = разовая вспышка в момент
обнаружения; cool-down не пульсирует». */
const SENSE_FLASH_MS = 3300;
const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved)
type MarkupTool = 'draw' | 'merge' | 'split' | 'opening' | 'openwall' | 'delroom';
@@ -246,6 +250,12 @@ class HouseplanCard extends LitElement {
private _zoom = 1;
private _view: { x: number; y: number; w: number; h: number } | null = null; // current SVG viewBox (vb coordinates)
private _zoomBySpace: Record<string, number> = {};
/**
* View-mode viewport remembered on entering an editor. Editor zoom is a
* working tool (zoom in to grab a vertex), not the user's intention for
* viewing — leaving any editor brings the view-mode viewport back.
*/
private _viewModeSnap: { space: string; zoom: number; cx?: number; cy?: number } | null = null;
private _pointers = new Map<number, { x: number; y: number }>();
private _panStart: { sx: number; sy: number; vx: number; vy: number } | null = null;
private _pinchStart: { dist: number; zoom: number } | null = null;
@@ -346,6 +356,13 @@ class HouseplanCard extends LitElement {
// ---- kiosk (wall device) mode ----
private _kioskScale: { icon: number; font: number } = { icon: 1, font: 1 };
private _kioskDialog = false;
/** motion-sensor runtime per marker: the last seen primary state and the
ts of the last off→on trip. Owner's rule (2026-08-01): «движение =
разовая вспышка в момент обнаружения; cool-down не пульсирует» —
the flash is keyed to the TRANSITION, not to the 'on' state. `timer`
is the one setTimeout per entry that repaints the card when the flash
window closes (cleared in disconnectedCallback). */
private _senseRt = new Map<string, { last: string; flashTs: number; timer: number }>();
/** live-vacuum runtime per marker: RAW robot coords (matrix applied at render) */
private _vacRt = new Map<string, { trail: VacPt[]; lastKey: string; lastTs: number;
moving: boolean; jump: boolean; endedTs: number; lastPos: VacPt | null }>();
@@ -461,6 +478,7 @@ class HouseplanCard extends LitElement {
public disconnectedCallback(): void {
document.removeEventListener('visibilitychange', this._vacVisHandler);
if (this._vacRaf) { cancelAnimationFrame(this._vacRaf); this._vacRaf = 0; }
for (const rt of this._senseRt.values()) clearTimeout(rt.timer); // pending flash-window repaints
window.removeEventListener('keydown', this._keyHandler);
clearInterval(this._cycleTimer);
clearTimeout(this._kioskDotsTimer);
@@ -807,7 +825,7 @@ class HouseplanCard extends LitElement {
}
protected willUpdate(changed: PropertyValues): void {
if (changed?.has?.('hass')) this._vacTick();
if (changed?.has?.('hass')) { this._vacTick(); this._senseTick(); }
if (changed.has('hass') && this.hass) {
if (!this._loadOk && !this._loading && this._loadTries < 8) {
this._loadFromServer();
@@ -1302,6 +1320,24 @@ class HouseplanCard extends LitElement {
const dc = p.attributes?.device_class;
if (['door', 'window', 'garage_door', 'opening', 'gas', 'smoke', 'moisture', 'problem'].includes(dc))
return p.state === 'on' ? 'open' : '';
// Owner's rule (2026-08-01, вариант «б»): «движение = разовая
// вспышка в момент обнаружения; cool-down не пульсирует; присутствие =
// статичное кольцо пока обитаемо». Neither takes the yellow 'on'
// fill — that stays reserved for «включено».
// MOTION flashes once per off→on trip: 'senseflash' lives only for
// the ~3.3s window after the transition (stamped by _senseTick on
// the hass tick) — a sensor still 'on' after that is in its
// cool-down, not seeing motion, so the class goes away even though
// the state has not changed. A new off→on trip re-arms the flash.
if (dc === 'motion') {
const rt = this._senseRt.get(d.id);
return rt && rt.flashTs && Date.now() - rt.flashTs < SENSE_FLASH_MS ? 'senseflash' : '';
}
// OCCUPANCY/PRESENCE while 'on': a calm STATIC ring ('sensehold',
// no animation in styles.ts) — «комната обитаема» is a state, not
// an event, so it must not blink.
if (['occupancy', 'presence'].includes(dc))
return p.state === 'on' ? 'sensehold' : '';
}
if (dom === 'media_player') return ['playing', 'on'].includes(p.state) ? 'on' : '';
if (dom === 'vacuum') return ['cleaning', 'returning'].includes(p.state) ? 'on' : '';
@@ -1537,8 +1573,16 @@ class HouseplanCard extends LitElement {
this._saveZoom();
}
/** Save the current space zoom to localStorage. */
/** Save the current space zoom to localStorage (view mode only). */
private _saveZoom(): void {
// Editor zoom is a working tool, never the viewing intent: while an editor
// is open the wheel/pinch keep calling _saveZoom, but the per-space VIEW
// zoom must not learn about it. The exit-editor restore used to re-save the
// view zoom from a rAF — on a slow tablet the floor-tab click lands BEFORE
// that rAF (input runs first in the frame), the space guard skipped the
// fix-up and the editor 500% stayed in _zoomBySpace/LS_ZOOM for the next
// visit to that floor. Editors do not need zoom persistence at all.
if (this._mode !== 'view') return;
this._zoomBySpace = { ...this._zoomBySpace, [this._space]: this._zoom };
try {
localStorage.setItem(LS_ZOOM, JSON.stringify(this._zoomBySpace));
@@ -1857,6 +1901,17 @@ class HouseplanCard extends LitElement {
return;
}
const baseChanges = !this._spaceModel().bg && (mode === 'view') !== (this._mode === 'view');
if (this._mode === 'view' && mode !== 'view') {
// remember the view-mode viewport: whatever zooming happens inside the
// editors is a working tool, not what the user wants to see afterwards
const v = this._view;
this._viewModeSnap = {
space: this._space,
zoom: this._zoom,
cx: v ? v.x + v.w / 2 : undefined,
cy: v ? v.y + v.h / 2 : undefined,
};
}
this._mode = mode;
if (baseChanges) {
// refit against the new base: the editors measure from the full square,
@@ -1865,6 +1920,22 @@ class HouseplanCard extends LitElement {
this._zoom = 1;
this._view = null; // updated() refits on the next frame
}
if (mode === 'view') {
const snap = this._viewModeSnap;
this._viewModeSnap = null;
// restore only for the space the snapshot was taken in — after a space
// switch inside the editor the saved per-space zoom already applies
if (snap && snap.space === this._space) {
this._zoom = snap.zoom;
this._view = null;
requestAnimationFrame(() => {
if (!this._stageEl || this._mode !== 'view' || this._space !== snap.space) return;
this._applyView(snap.zoom, snap.cx, snap.cy);
this._saveZoom(); // editor wheel zoom wrote itself to LS_ZOOM — put the view zoom back
this.requestUpdate();
});
}
}
this._path = [];
this._cursorPt = null;
this._tool = 'draw';
@@ -4374,6 +4445,33 @@ class HouseplanCard extends LitElement {
return !!this._vacEntity(d);
}
/**
* Motion-flash bookkeeping, one pass per hass tick (the _vacTick pattern):
* remembers each motion marker's last primary state and stamps flashTs on
* an off→on transition. Only a WITNESSED 'off' → 'on' step counts — a
* sensor first seen already 'on' (page load mid cool-down) or coming back
* from 'unavailable' is a reconnect, not a fresh detection, and must not
* flash. Each stamp (re)arms ONE setTimeout that calls requestUpdate when
* the ~3.3s window closes, so _stateClass drops 'senseflash' even though
* no new hass tick arrives; disconnectedCallback clears the timers.
*/
private _senseTick(): void {
if (!this.hass) return;
for (const d of this._devices) {
if (d.hidden || !d.primary || !d.primary.startsWith('binary_sensor.')) continue;
const p = this.hass.states[d.primary];
if (p?.attributes?.device_class !== 'motion') continue;
const rt = this._senseRt.get(d.id);
if (!rt) { this._senseRt.set(d.id, { last: p.state, flashTs: 0, timer: 0 }); continue; }
if (p.state === 'on' && rt.last === 'off') {
rt.flashTs = Date.now();
clearTimeout(rt.timer);
rt.timer = window.setTimeout(() => this.requestUpdate(), SENSE_FLASH_MS + 60);
}
rt.last = p.state;
}
}
/**
* Trail buffers live OUTSIDE render: every hass tick appends the raw robot
* position, so the trail survives view switches and zoom without ever being
+66 -17
View File
@@ -866,12 +866,16 @@ export const cardStyles = css`
width: 130px;
}
.dev.valonly {
/* all satellite metrics from --dev-size, not --icon-size: the per-device
size multiplier must scale the value plate with its marker — same bug
class as the v1.51.3 glyph fix (pinned to base size, the multiplier
grew nothing). */
width: auto;
min-width: var(--dev-size, var(--icon-size, 2.5cqw));
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.16);
padding: 0 calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.16);
}
.dev.valonly .valtext {
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
font-weight: 700;
white-space: nowrap;
}
@@ -879,11 +883,52 @@ export const cardStyles = css`
/* v1.52.0: the RGB tint of the icon/border is gone — a lamp's colour
lives ONLY in its glow spot (owner's rule). The ripple-color fallback
keeps using the light colour; that is set inline via --ripple-color. */
/* Owner's rule (2026-08-01, вариант «б»): «движение = разовая вспышка
в момент обнаружения; cool-down не пульсирует; присутствие =
статичное кольцо пока обитаемо». The yellow FILL stays reserved for
«включено» — both sense states keep the neutral badge. Both rings
are declared BEFORE .dev.alarm on purpose: equal specificity on the
shared ::after means the later alarm rule wins if a device ever
carries both — red beats yellow. */
/* MOTION tripped: a short ONE-SHOT flash — three beats of the yellow
ring (3 × 1.1s), then silence, even while the entity still reports
'on' (that tail is the sensor's cool-down, not motion). The
iteration count is FINITE, and the card itself drops the class
~3.3s after the off→on transition (_senseTick/_stateClass in
houseplan-card.ts); base opacity 0 keeps the ring invisible should
the class outlive the animation by a frame. */
.dev.senseflash::after {
content: '';
position: absolute;
inset: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.35);
border: 2px solid var(--hp-on);
border-radius: 50%;
opacity: 0;
animation: hp-sense 1.1s ease-in-out 3;
pointer-events: none;
}
/* OCCUPANCY/PRESENCE while 'on': a calm STATIC ring, no animation —
«комната обитаема» is a state, not an event, so it must not blink.
Brightness matches the reduced-motion variant of the flash. */
.dev.sensehold::after {
content: '';
position: absolute;
inset: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.35);
border: 2px solid var(--hp-on);
border-radius: 50%;
opacity: 0.4;
pointer-events: none;
}
@keyframes hp-sense {
0% { transform: scale(0.9); opacity: 0.5; }
60% { transform: scale(1.12); opacity: 0.12; }
100% { transform: scale(1.12); opacity: 0; }
}
/* alarms pulse red over everything */
.dev.alarm::after {
content: '';
position: absolute;
inset: calc(var(--icon-size, 2.5cqw) * -0.35);
inset: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.35);
border: 3px solid #f25a4a;
border-radius: 50%;
animation: hp-alarm 1s ease-out infinite;
@@ -895,13 +940,17 @@ export const cardStyles = css`
}
@media (prefers-reduced-motion: reduce) {
.dev.alarm::after { animation: none; opacity: 0.9; }
/* the motion flash becomes a static ring for the same ~3.3s class
window — consistent with alarm's treatment; sensehold is static
by design already */
.dev.senseflash::after { animation: none; opacity: 0.4; }
}
.dev .newdot {
position: absolute;
top: calc(var(--icon-size, 2.5cqw) * -0.12);
right: calc(var(--icon-size, 2.5cqw) * -0.12);
width: calc(var(--icon-size, 2.5cqw) * 0.34);
height: calc(var(--icon-size, 2.5cqw) * 0.34);
top: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.12);
right: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.12);
width: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.34);
height: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.34);
border-radius: 50%;
background: #f0301f;
border: 2px solid var(--card-background-color, var(--hp-bg));
@@ -993,14 +1042,14 @@ export const cardStyles = css`
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
margin-left: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.1);
background: var(--card-background-color, var(--hp-bg));
border: 1px solid var(--hp-accent);
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
border-radius: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.18);
padding: 0 calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.14);
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
font-weight: 700;
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
line-height: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.68);
color: var(--hp-txt);
white-space: nowrap;
pointer-events: none;
@@ -1010,14 +1059,14 @@ export const cardStyles = css`
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
margin-left: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.1);
background: var(--card-background-color, var(--hp-bg));
border: 1px solid #4fc3f7;
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
border-radius: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.18);
padding: 0 calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.14);
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
font-weight: 700;
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
line-height: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.68);
color: var(--hp-txt);
white-space: nowrap;
pointer-events: none;
@@ -1027,8 +1076,8 @@ export const cardStyles = css`
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: calc(var(--icon-size, 2.5cqw) * 0.05);
font-size: calc(var(--icon-size, 2.5cqw) * 0.38);
margin-top: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.05);
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.38);
font-weight: 700;
line-height: 1;
text-shadow: 0 0 3px rgba(0, 0, 0, 0.9), 0 0 2px rgba(0, 0, 0, 0.9);