mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
v1.54.3
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
);
|
||||
@@ -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);
|
||||
})();
|
||||
Vendored
+71
-22
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user