mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 13:18:58 +00:00
HP-1543-01 (medium): exiting an editor AFTER switching floors inside it left the editor working zoom on screen in view mode — the snapshot guard (snap.space) dropped the restore and nothing else put the viewport back. A space-mismatched exit now falls back to _restoreZoom() of the current floor's saved view zoom (per-space store/LS_ZOOM were never polluted, the view-only _saveZoom guard is untouched). Regression: crossFloor* asserts in demo/smoke_zoom_out.mjs — red on v1.54.3, green now. HP-1543-02 (low): a rapid off->on retrip before the current flash ended did not restart the CSS animation: the senseflash class and animation-name never changed, so the browser kept the old timeline and the second detection played nothing once the first one-shot had finished (base opacity 0). Every witnessed trip now bumps a generation counter; its parity alternates the identical keyframes hp-sense / hp-sense-b via the .sf2 marker — a new animation identity forces a fresh timeline per detection. flashTs and the window timer re-arm as before; prefers-reduced-motion keeps the static ring (retrip only extends the window). Regression: rapid* asserts in demo/smoke_motion_sense.mjs — red on v1.54.3, green now.
123 lines
8.3 KiB
JavaScript
123 lines
8.3 KiB
JavaScript
// Правило владельца (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;
|
||
|
||
// --- HP-1543-02: быстрый повторный off→on ДО конца текущей вспышки ------
|
||
// обязан ПЕРЕЗАПУСТИТЬ анимацию (новый timeline). Раньше класс senseflash
|
||
// не снимался и animation-name не менялся — браузер продолжал старый
|
||
// timeline, и после его конца (base opacity 0) второй trip был невидим.
|
||
await sleep(3600); await c.updateComplete; // дать текущей вспышке догореть
|
||
const senseAnim = (el) => el && el.getAnimations({ subtree: true })
|
||
.find((a) => a.animationName && a.animationName.startsWith('hp-sense'));
|
||
await setMotion('off', 'motion');
|
||
await setMotion('on', 'motion'); // trip №1
|
||
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.senseflash').length === 1;
|
||
await sleep(1200); // середина первой вспышки (< 3.3s)
|
||
const a1 = senseAnim(sr().querySelector('.dev.senseflash'));
|
||
out.rapidFirstAnimMidway = !!a1 && a1.currentTime > 900 && a1.currentTime < 3300;
|
||
await setMotion('off', 'motion');
|
||
await setMotion('on', 'motion'); // trip №2 — retrip до конца вспышки
|
||
out.rapidRetripKeepsFlash = sr().querySelectorAll('.dev.senseflash').length === 1;
|
||
const a2 = senseAnim(sr().querySelector('.dev.senseflash'));
|
||
// новый timeline: currentTime у начала (старый уже был ~1.2s и продолжал бы расти)
|
||
out.rapidRetripRestartsAnim = !!a2 && a2.currentTime != null && a2.currentTime < 300;
|
||
// кольцо реально играет ПОСЛЕ момента, где закончилась бы первая анимация:
|
||
// ~2.3s после trip №2 = ~3.5s после trip №1 (старый timeline уже отыграл бы)
|
||
await sleep(2300);
|
||
const el3 = sr().querySelector('.dev.senseflash');
|
||
const a3 = senseAnim(el3);
|
||
out.rapidSecondFlashStillPlays = !!a3 && a3.playState === 'running';
|
||
out.rapidRingVisibleAfterFirstWindow = !!el3
|
||
&& parseFloat(getComputedStyle(el3, '::after').opacity) > 0.03;
|
||
// и класс уходит через полные ~3.3s после ВТОРОГО trip (flashTs/таймер обновлены)
|
||
await sleep(1400); await c.updateComplete;
|
||
out.rapidFlashEndsAfterSecondWindow = sr().querySelectorAll('.dev.senseflash').length === 0;
|
||
|
||
// --- 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);
|