motion sensors: tripped motion/occupancy/presence pulses a soft yellow ring
Validate / hacs (push) Failing after 42s
Validate / hassfest (push) Failing after 40s
Validate / frontend (push) Successful in 1m33s
Validate / backend (push) Failing after 6m16s
Validate / smoke (push) Successful in 5m8s

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:
Matysh
2026-08-01 11:54:23 +03:00
parent 88008961e3
commit 29dae45e2a
7 changed files with 196 additions and 3 deletions
File diff suppressed because one or more lines are too long
+25
View File
@@ -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');
+61
View File
@@ -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
+26 -1
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -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' : '';
+25
View File
@@ -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;