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:
Matysh
2026-08-01 12:17:17 +03:00
parent 29dae45e2a
commit ca07579b63
7 changed files with 270 additions and 104 deletions
File diff suppressed because one or more lines are too long
+12 -6
View File
@@ -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
View File
@@ -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
+37 -17
View File
File diff suppressed because one or more lines are too long
+58 -8
View File
@@ -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
View File
@@ -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;