mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
motion sensors: tripped motion/occupancy/presence pulses a soft yellow ring
Owner's rule (2026-08-01): the yellow FILL is reserved for 'on' — a tripped sensor keeps the neutral badge and gets a new 'sense' state class instead: a gentle .dev.sense::after ring in the .dev.on yellow (--hp-on), 2.4s period, max opacity 0.5 — the soft sibling of the red .dev.alarm siren. Declared before .dev.alarm so red wins on the shared ::after if both ever apply; ghosts don't pulse (hidden gate already strips the state class). prefers-reduced-motion: static faint ring, same treatment as alarm. Smoke smoke_motion_sense.mjs (fails on the pre-fix bundle): sense class on/off, ::after animation, neutral background (not --hp-on), occupancy/presence, hidden ghost. shot_motion_sense.mjs captures the ring for review.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,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;
|
||||
// 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;}';
|
||||
(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 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');
|
||||
@@ -0,0 +1,61 @@
|
||||
// Правило владельца (2026-08-01): «жёлтая подложка = включено; срабатывание
|
||||
// сенсора = лёгкая жёлтая пульсация». Датчик движения/присутствия при state=on
|
||||
// получает класс 'sense' — мягкое жёлтое пульсирующее кольцо (::after,
|
||||
// hp-sense), но НЕ жёлтую заливку .dev.on. off → без класса; 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;
|
||||
};
|
||||
|
||||
// --- motion on: класс sense, кольцо анимировано, подложка НЕ жёлтая ----
|
||||
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';
|
||||
// подложка нейтральная — как у обычного .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('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
|
||||
|
||||
// --- off: класс уходит ---------------------------------------------------
|
||||
await setMotion('off', 'motion');
|
||||
out.offClearsSense = sr().querySelectorAll('.dev.sense').length === 0;
|
||||
|
||||
// --- occupancy / presence аналогично ------------------------------------
|
||||
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');
|
||||
|
||||
// --- ghost не пульсирует (призрак — конфигурация, не статус) ------------
|
||||
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;
|
||||
c._showHidden = true; c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
const ghost = sr().querySelector('.dev.ghost');
|
||||
out.ghostNoSense = !!ghost && !ghost.classList.contains('sense')
|
||||
&& getComputedStyle(ghost, '::after').animationName !== 'hp-sense';
|
||||
return out;
|
||||
});
|
||||
checkAll(res, {});
|
||||
await finish(browser, res);
|
||||
File diff suppressed because one or more lines are too long
Vendored
+26
-1
File diff suppressed because one or more lines are too long
@@ -1308,6 +1308,13 @@ 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' : '';
|
||||
}
|
||||
if (dom === 'media_player') return ['playing', 'on'].includes(p.state) ? 'on' : '';
|
||||
if (dom === 'vacuum') return ['cleaning', 'returning'].includes(p.state) ? 'on' : '';
|
||||
|
||||
@@ -883,6 +883,29 @@ 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 {
|
||||
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;
|
||||
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: '';
|
||||
@@ -899,6 +922,8 @@ 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; }
|
||||
}
|
||||
.dev .newdot {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user