mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +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;
|
const dc = p.attributes?.device_class;
|
||||||
if (['door', 'window', 'garage_door', 'opening', 'gas', 'smoke', 'moisture', 'problem'].includes(dc))
|
if (['door', 'window', 'garage_door', 'opening', 'gas', 'smoke', 'moisture', 'problem'].includes(dc))
|
||||||
return p.state === 'on' ? 'open' : '';
|
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 === 'media_player') return ['playing', 'on'].includes(p.state) ? 'on' : '';
|
||||||
if (dom === 'vacuum') return ['cleaning', 'returning'].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
|
/* 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
|
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. */
|
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 */
|
/* alarms pulse red over everything */
|
||||||
.dev.alarm::after {
|
.dev.alarm::after {
|
||||||
content: '';
|
content: '';
|
||||||
@@ -899,6 +922,8 @@ export const cardStyles = css`
|
|||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.dev.alarm::after { animation: none; opacity: 0.9; }
|
.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 {
|
.dev .newdot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
Reference in New Issue
Block a user