mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
motion sensors: one-shot flash on detection; occupancy/presence hold a static ring
Owner's contract (2026-08-01, вариант «б») replaces yesterday's 'sense' class
(29dae45, lived <1 day, stored nowhere — no migration):
- MOTION (device_class motion): a SHORT one-shot flash at the off→on
transition — 3 beats of the yellow ring (hp-sense 1.1s x3, ~3.3s), then
silence even while the entity still reports 'on': «движение = разовая
вспышка в момент обнаружения; cool-down не пульсирует». A new off→on trip
flashes again. Tracking lives in _senseRt (markerId → last state + flash
ts), stamped by _senseTick on the hass tick (the _vacTick pattern); one
setTimeout per entry repaints the card when the window closes so the
'senseflash' class is dropped without a state change (cleared in
disconnectedCallback). First sight already-'on' and unavailable→on do not
flash — not a witnessed detection.
- OCCUPANCY/PRESENCE while 'on': 'sensehold' — a STATIC yellow ring, no
animation, opacity 0.4 (the reduced-motion brightness): «присутствие =
статичное кольцо пока обитаемо».
- Unchanged: the yellow FILL stays reserved for «включено», alarm's red ring
wins on the shared ::after, ghosts draw nothing.
- prefers-reduced-motion: the flash becomes a static ring for the same ~3.3s
window (consistent with alarm); sensehold is static by design.
Smoke smoke_motion_sense.mjs rewritten to the new contract (13 asserts fail
on the pre-fix bundle): finite iteration count '3', flash gone after 3.6s
while state is still 'on' (the key assert), re-trip flashes again,
occupancy/presence static ring, neutral badge, hidden ghost inert.
shot_motion_sense.mjs now cycles off→on to arm the flash.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -2,19 +2,25 @@ import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state: 'on',
|
||||
attributes: { friendly_name: 'Motion', device_class: 'motion', linkquality: 64 } } } };
|
||||
await c.updateComplete;
|
||||
// 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.sense::after{animation-delay:-0.4s;animation-play-state:paused;}';
|
||||
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.sense');
|
||||
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 };
|
||||
});
|
||||
|
||||
+56
-26
@@ -1,8 +1,13 @@
|
||||
// Правило владельца (2026-08-01): «жёлтая подложка = включено; срабатывание
|
||||
// сенсора = лёгкая жёлтая пульсация». Датчик движения/присутствия при state=on
|
||||
// получает класс 'sense' — мягкое жёлтое пульсирующее кольцо (::after,
|
||||
// hp-sense), но НЕ жёлтую заливку .dev.on. off → без класса; ghost — без
|
||||
// пульсации (призрак — конфигурация, не статус).
|
||||
// Правило владельца (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 () => {
|
||||
@@ -15,45 +20,70 @@ const res = await page.evaluate(async () => {
|
||||
attributes: { friendly_name: 'Motion', device_class, linkquality: 64 } } } };
|
||||
await c.updateComplete;
|
||||
};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// --- motion on: класс sense, кольцо анимировано, подложка НЕ жёлтая ----
|
||||
// --- motion off→on: вспышка senseflash, 3 удара, подложка НЕ жёлтая -----
|
||||
await setMotion('off', 'motion');
|
||||
out.offNoFlash = sr().querySelectorAll('.dev.senseflash, .dev.sensehold').length === 0;
|
||||
await setMotion('on', 'motion');
|
||||
const senseEl = sr().querySelector('.dev.sense');
|
||||
out.motionOnHasSense = !!senseEl;
|
||||
out.senseIsNotOn = !!senseEl && !senseEl.classList.contains('on') && !senseEl.classList.contains('open');
|
||||
const after = senseEl ? getComputedStyle(senseEl, '::after') : null;
|
||||
out.ringAnimated = !!after && after.animationName === 'hp-sense' && after.animationDuration === '2.4s';
|
||||
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('sense') && !e.classList.contains('on') && !e.classList.contains('open')
|
||||
!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 = senseEl ? getComputedStyle(senseEl).backgroundColor : '';
|
||||
out.bgIsNeutral = !!neutral && bg === getComputedStyle(neutral).backgroundColor;
|
||||
out.bgNotYellow = bg !== '' && bg !== 'rgb(255, 212, 92)'; // --hp-on
|
||||
const bg = flashEl ? getComputedStyle(flashEl).backgroundColor : '';
|
||||
out.flashBgNeutral = !!neutral && bg === getComputedStyle(neutral).backgroundColor;
|
||||
out.flashBgNotYellow = bg !== '' && bg !== 'rgb(255, 212, 92)'; // --hp-on
|
||||
|
||||
// --- off: класс уходит ---------------------------------------------------
|
||||
// --- КЛЮЧЕВОЙ ассерт: вспышка гаснет, хотя 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');
|
||||
out.offClearsSense = sr().querySelectorAll('.dev.sense').length === 0;
|
||||
await setMotion('on', 'motion');
|
||||
out.retripFlashesAgain = sr().querySelectorAll('.dev.senseflash').length === 1;
|
||||
|
||||
// --- occupancy / presence аналогично ------------------------------------
|
||||
// --- occupancy: статичное кольцо sensehold, без анимации ----------------
|
||||
await setMotion('on', 'occupancy');
|
||||
out.occupancySenses = sr().querySelectorAll('.dev.sense').length === 1;
|
||||
await setMotion('on', 'presence');
|
||||
out.presenceSenses = sr().querySelectorAll('.dev.sense').length === 1;
|
||||
// и это именно sense, не тревога и не «открыто»
|
||||
out.noAlarmNoOpen = !sr().querySelector('.dev.sense.alarm') && !sr().querySelector('.dev.sense.open');
|
||||
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;
|
||||
|
||||
// --- ghost не пульсирует (призрак — конфигурация, не статус) ------------
|
||||
// --- 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.sense').length === 0;
|
||||
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('sense')
|
||||
out.ghostNoSense = !!ghost && !ghost.classList.contains('senseflash') && !ghost.classList.contains('sensehold')
|
||||
&& getComputedStyle(ghost, '::after').animationName !== 'hp-sense';
|
||||
return out;
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+37
-17
File diff suppressed because one or more lines are too long
+58
-8
@@ -49,6 +49,10 @@ 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';
|
||||
@@ -352,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 }>();
|
||||
@@ -467,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);
|
||||
@@ -813,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();
|
||||
@@ -1308,13 +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): «жёлтая подложка = включено; срабатывание
|
||||
// сенсора = лёгкая жёлтая пульсация». A tripped motion/occupancy/
|
||||
// presence sensor must NOT take the yellow 'on' fill — it keeps the
|
||||
// neutral badge and gets a soft yellow pulse ring instead (.dev.sense
|
||||
// in styles.ts, the gentle sibling of the red .dev.alarm ring).
|
||||
if (['motion', 'occupancy', 'presence'].includes(dc))
|
||||
return p.state === 'on' ? 'sense' : '';
|
||||
// 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' : '';
|
||||
@@ -4422,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
|
||||
|
||||
+33
-13
@@ -883,22 +883,40 @@ 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. */
|
||||
/* motion/occupancy/presence tripped: a soft yellow pulse ring around
|
||||
the icon. Owner's rule (2026-08-01): «жёлтая подложка = включено;
|
||||
срабатывание сенсора = лёгкая жёлтая пульсация» — the yellow FILL
|
||||
stays reserved for the 'on' state, so a tripped sensor keeps the
|
||||
neutral badge and only breathes. Same ring mechanics as .dev.alarm
|
||||
below, but gentler: the .dev.on yellow (--hp-on), half the opacity,
|
||||
a slower period and a smaller amplitude. Declared BEFORE .dev.alarm
|
||||
on purpose: equal specificity on the same ::after means the later
|
||||
alarm rule wins if a device ever carries both — red beats yellow. */
|
||||
.dev.sense::after {
|
||||
/* 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%;
|
||||
animation: hp-sense 2.4s ease-in-out infinite;
|
||||
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 {
|
||||
@@ -922,8 +940,10 @@ export const cardStyles = css`
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dev.alarm::after { animation: none; opacity: 0.9; }
|
||||
/* static counterpart, same as alarm's: a faint steady yellow ring */
|
||||
.dev.sense::after { animation: none; opacity: 0.4; }
|
||||
/* 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;
|
||||
|
||||
Reference in New Issue
Block a user