fix: align device compatibility guards and vacuum puck

Issue: #179
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-19 22:58:01 +03:00
parent 6fdb7dce1a
commit 9daa2e91fd
16 changed files with 109 additions and 69 deletions
File diff suppressed because one or more lines are too long
+19 -10
View File
@@ -7,9 +7,10 @@
// --hp-open #ff9f43, not just a border), and the travelling states carried it
// underneath the breathing ring. Now a cover returns no plate class at all:
//
// closed / open / ajar -> neutral plate, the ICON tells the state
// closed / open / ajar -> neutral core, the ICON tells the state
// opening / closing -> neutral plate + activity-transition when the
// marker opts into «Icon + activity»
// unknown / unavailable -> package unavailable core, without a morph
//
// The frame itself is NOT gone: door/window binary sensors and locks still
// wear it, and a valve keeps it too (nothing morphs its icon). Those are
@@ -33,7 +34,8 @@ const out = await page.evaluate(async () => {
};
const YELLOW = probe('var(--hp-on)'); // «включено»
const ORANGE = probe('var(--hp-open)'); // «открыто» frame
const NEUTRAL = probe('var(--hp-bg)'); // the plain badge
const NEUTRAL = probe('light-dark(#fff, #252525)'); // package theme fallback
const AMBER = probe('#F0A00C'); // package working/unlocked core
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
o.yellowIsTheYellow = YELLOW === 'rgb(255, 212, 92)';
o.orangeIsTheOpenFrame = ORANGE === 'rgb(255, 159, 67)';
@@ -71,7 +73,7 @@ const out = await page.evaluate(async () => {
const devEl = () => sr().querySelector('.devlayer .dev');
const cls = () => [...(devEl()?.classList || [])];
const plate = () => getComputedStyle(devEl()).backgroundColor;
const plate = () => getComputedStyle(devEl()?.querySelector('.device-core')).backgroundColor;
const icon = () => devEl()?.querySelector('ha-icon')?.getAttribute('icon') || '';
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.devlayer .dev').length === 1;
@@ -105,7 +107,7 @@ const out = await page.evaluate(async () => {
o.openingRingIsTheBreathingOne = (() => {
const ring = devEl()?.querySelector('.device-pulse.continuous.transition i:first-child');
const cs = ring ? getComputedStyle(ring) : null;
return !!cs && cs.animationName === 'hp-pulse-continuous' && cs.animationDuration === '2.4s';
return !!cs && cs.animationName === 'hp-pulse-continuous' && cs.animationDuration === '3.6s';
})();
await setCover('closing', { device_class: 'curtain' });
@@ -134,9 +136,12 @@ const out = await page.evaluate(async () => {
// one the name rule «ворота|garage|gate» hands out, and it morphs too
o.gateAutoIcon = c._devices.find((x) => x.bindingRef === 'd_gate')?.icon === 'mdi:garage-variant';
o.morphNoClass = await morph('', 'mdi:garage-variant', 'mdi:garage-open-variant');
// …and an unreadable state morphs nothing, plate still neutral
// …and an unreadable state morphs nothing and uses the package unavailable core.
await setCover('unknown', { device_class: 'curtain' });
plateIsNeutral('unknown');
o.unknownPlateUnavailable = plate() === probe('#B5BAC1');
o.unknownNotYellow = plate() !== YELLOW;
o.unknownNotOrange = plate() !== ORANGE;
o.unknownNoPlateClass = !cls().includes('on') && !cls().includes('open');
o.unknownNoMorph = icon() === 'mdi:garage-variant';
o.unknownNoRing = !cls().includes('activity-transition');
@@ -164,17 +169,21 @@ const out = await page.evaluate(async () => {
const winEl = elOf(dev('d_window'));
o.lockMarkerFound = !!lockEl;
o.windowMarkerFound = !!winEl;
o.unlockedLockKeepsTheFrame = !!lockEl && lockEl.classList.contains('open')
&& getComputedStyle(lockEl).backgroundColor === ORANGE;
o.unlockedLockKeepsTheFrame = !!lockEl && lockEl.classList.contains('lock-unlocked')
&& getComputedStyle(lockEl.querySelector('.device-core')).backgroundColor === AMBER;
o.openWindowKeepsTheFrame = !!winEl && winEl.classList.contains('open')
&& getComputedStyle(winEl).backgroundColor === ORANGE;
&& getComputedStyle(winEl.querySelector('.device-core')).backgroundColor === ORANGE;
// …and a locked lock is neutral again, so the frame still MEANS something
c.hass = { ...c.hass, states: { ...c.hass.states,
'lock.front_door': { entity_id: 'lock.front_door', state: 'locked', attributes: { friendly_name: 'Front door' } } } };
c.requestUpdate();
await c.updateComplete;
await new Promise((r) => setTimeout(r, 300));
o.lockedLockIsNeutral = getComputedStyle(elOf(dev('d_lock'))).backgroundColor === NEUTRAL;
const lockedEl = elOf(dev('d_lock'));
const lockedCore = lockedEl?.querySelector('.device-core');
o.lockedLockIsNeutral = !!lockedEl?.classList.contains('lock-locked')
&& getComputedStyle(lockedCore).backgroundColor === NEUTRAL
&& getComputedStyle(lockedCore).color === 'rgb(0, 0, 0)';
// a VALVE keeps the frame too: nothing morphs its icon, so the frame is the
// only thing it has to say «открыт» with (deliberately left alone)
+5 -3
View File
@@ -37,7 +37,7 @@ const out = await page.evaluate(async () => {
};
const YELLOW = probe('var(--hp-on)');
const ORANGE = probe('var(--hp-open)');
const NEUTRAL = probe('var(--hp-bg)');
const NEUTRAL = probe('light-dark(#fff, #252525)');
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
// =======================================================================
@@ -197,7 +197,7 @@ const out = await page.evaluate(async () => {
const el = d && elOf(d);
o[tag + 'MarkerFound'] = !!el;
if (!el) return;
const bg = getComputedStyle(el).backgroundColor;
const bg = getComputedStyle(el.querySelector('.device-core')).backgroundColor;
o[tag + 'PlateNeutral'] = bg === NEUTRAL;
o[tag + 'NotYellow'] = bg !== YELLOW;
o[tag + 'NotOrange'] = bg !== ORANGE;
@@ -212,7 +212,9 @@ const out = await page.evaluate(async () => {
// …and at rest (closed) they are plain, with no ring
await push('closed');
const closedBg = (ref) => getComputedStyle(elOf(dev(ref))).backgroundColor;
const closedBg = (ref) => getComputedStyle(
elOf(dev(ref)).querySelector('.device-core'),
).backgroundColor;
o.litLampClosedNeutral = closedBg('d_mixed') === NEUTRAL;
o.controlledClosedNeutral = closedBg('d_ctrl') === NEUTRAL;
o.litLampClosedNoRing = !elOf(dev('d_mixed')).classList.contains('activity-transition');
+3 -2
View File
@@ -44,8 +44,9 @@ Object.assign(out, await page.evaluate(async () => {
o.ghostHasNoState = !!g && !g.classList.contains('on') && !g.classList.contains('open')
&& !g.classList.contains('unavail') && !g.classList.contains('alarm');
// и он синий, а не тёмный — отличим от недоступного устройства
o.ghostIsBlue = !!g && getComputedStyle(g).borderStyle.includes('dashed')
&& getComputedStyle(g).borderColor !== 'rgb(255, 255, 255)';
const ghostShell = g?.querySelector('.device-shell');
o.ghostIsBlue = !!ghostShell && getComputedStyle(ghostShell).borderStyle.includes('dashed')
&& getComputedStyle(ghostShell).borderColor !== 'rgb(255, 255, 255)';
// тумблер локальный: конфиг не трогается
o.toggleIsLocal = c._serverCfg.settings.show_all === undefined;
+6 -6
View File
@@ -57,10 +57,10 @@ const out2 = await page.evaluate(async () => {
await new Promise((r) => setTimeout(r, 80));
};
const fs = (el) => parseFloat(getComputedStyle(el).fontSize);
// базовый --icon-size в px: ширина немасштабированного бейджа минус рамка 1px×2
// Базовый --icon-size в px — фактический package core без shell stroke.
const plain = [...sr().querySelectorAll('.dev')].find(
(e) => !e.classList.contains('ghost') && !e.classList.contains('valonly') && !(e.getAttribute('style') || '').includes('--dev-scale'));
const iconPx = plain.getBoundingClientRect().width - 2;
const iconPx = plain.querySelector('.device-core').getBoundingClientRect().width;
// --- бейдж значения (valonly) ---
await setMarker({ display: 'value', size: 1 });
@@ -72,19 +72,19 @@ const out2 = await page.evaluate(async () => {
const v2 = devEl().getBoundingClientRect();
const vf2 = fs(devEl().querySelector('.valtext'));
o.valFontScales = vf2 / vf1 > 1.8 && vf2 / vf1 < 2.2;
o.valPlateScales = (v2.height - 2) / (v1.height - 2) > 1.8 && (v2.height - 2) / (v1.height - 2) < 2.2
o.valPlateScales = v2.height / v1.height > 1.8 && v2.height / v1.height < 2.2
&& v2.width / v1.width > 1.6 && v2.width / v1.width < 2.4;
// --- temp-плашка (.tval) рядом со значком ---
await setMarker({ size: 1 });
const t1 = devEl().querySelector('.value-badge').getBoundingClientRect();
const tf1 = fs(devEl().querySelector('.value-badge'));
// дефолт: line-height 0.68 * icon-size + рамка 1px×2
o.tvalDefaultKept = Math.abs(t1.height - (iconPx * 0.68 + 2)) < 1;
// Package Double section height is 0.7875 of the icon core.
o.tvalDefaultKept = Math.abs(t1.height - iconPx * 0.7875) < 1;
await setMarker({ size: 2 });
const t2 = devEl().querySelector('.value-badge').getBoundingClientRect();
const tf2 = fs(devEl().querySelector('.value-badge'));
o.tvalScales = (t2.height - 2) / (t1.height - 2) > 1.8 && (t2.height - 2) / (t1.height - 2) < 2.2
o.tvalScales = t2.height / t1.height > 1.8 && t2.height / t1.height < 2.2
&& tf2 / tf1 > 1.8 && tf2 / tf1 < 2.2;
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== 'd_temp');
+4 -4
View File
@@ -248,11 +248,11 @@ Object.assign(out, await page.evaluate(async () => {
const farIcon = await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
// the badge is --dev-size PLUS a 1px border on each side, and the border
// is not part of the scaled geometry (same correction as smoke_icon_scale)
// Measure the package core: shell stroke/shadow are decoration, while the
// saved icon geometry must still scale with the plan.
const size = () => {
const el = sr.querySelector('.devlayer .dev');
return Math.round((el.getBoundingClientRect().width - 2) * 100) / 100;
const el = sr.querySelector('.devlayer .dev .device-core');
return Math.round(el.getBoundingClientRect().width * 100) / 100;
};
const o = {};
c._resetZoom(); await c.updateComplete;
+1 -1
View File
@@ -118,7 +118,7 @@ const res = await page.evaluate(async () => {
const holdRing = holdEl?.querySelector('.device-pulse.continuous.presence i:first-child');
const hAfter = holdRing ? getComputedStyle(holdRing) : null;
out.holdAnimated = !!hAfter && hAfter.animationName === 'hp-pulse-continuous';
out.holdAnimationCalm = !!hAfter && hAfter.animationDuration === '2.4s';
out.holdAnimationCalm = !!hAfter && hAfter.animationDuration === '3.6s';
out.holdBgNeutral = !!holdEl && !!neutral
&& getComputedStyle(holdEl).backgroundColor === getComputedStyle(neutral).backgroundColor;
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('activity-event');
+6 -4
View File
@@ -39,11 +39,13 @@ const out = await page.evaluate(async () => {
// owner 2026-07-31: the base badge, but round and 20% smaller — same plate
// vs a NEUTRAL badge: the robot's own base is yellow while cleaning
const devEl = sr().querySelector('.dev:not(.on)');
const pcs = getComputedStyle(puck), dcs = getComputedStyle(devEl);
o.puckPlateMatchesDev = pcs.backgroundColor === dcs.backgroundColor
&& pcs.borderTopColor === dcs.borderTopColor;
const devCore = devEl.querySelector('.device-core');
const devShell = devEl.querySelector('.device-shell');
const pcs = getComputedStyle(puck);
o.puckPlateMatchesDev = pcs.backgroundColor === getComputedStyle(devCore).backgroundColor
&& pcs.borderTopColor === getComputedStyle(devShell).borderTopColor;
o.puck80pct = Math.abs(puck.getBoundingClientRect().width
- devEl.getBoundingClientRect().width * 0.8) < 2;
- devCore.getBoundingClientRect().width * 0.8) < 2;
o.noWedge = !sr().querySelector('.vacwedge'); // owner 2026-07-31: no heading arrow
// the glyph is DEAD centre (owner report: it floated on the text baseline)
const pr = puck.getBoundingClientRect();
File diff suppressed because one or more lines are too long
+13 -7
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -8,6 +8,7 @@
in one shell and are no longer ellipsized; marker LQI uses red at 0–40,
amber at 41–179 and green at 180+. Activity motion follows the new package
timings and colors, while saved custom pulse settings remain authoritative.
The live vacuum puck uses the same theme-aware core, stroke and shadow.
Interactive markers also have a 44×44 px target and reuse their existing
click/confirmation path from Enter or Space
([#179](https://github.com/Matysh/houseplan-card/issues/179)).
+1
View File
@@ -15,6 +15,7 @@
не сокращаются многоточием; LQI маркера окрашивается красным при 0–40,
жёлтым при 41–179 и зелёным при 180+. Пульсации получили новые тайминги и
цвета, а явно сохранённые пользовательские настройки сохранили приоритет.
Живой маркер робота-пылесоса использует те же цвета темы, обводку и тень.
Интерактивный маркер также имеет область нажатия не меньше 44×44 px и по
Enter/Space использует прежний click/confirmation path
([#179](https://github.com/Matysh/houseplan-card/issues/179)).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 291 KiB

+12 -12
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"sourceFingerprint": "0626dfc9748c390b8b2ad35a5abbab2bb5e8ae02048c2f081d77aeea548f34a0",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -13,7 +13,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"sourceSha256": "0626dfc9748c390b8b2ad35a5abbab2bb5e8ae02048c2f081d77aeea548f34a0",
"imageSha256": "e2d31553c2541fd7f0725db348e3d0210867d4e2489532372270820bc0050794"
},
"view-touch": {
@@ -24,7 +24,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"sourceSha256": "0626dfc9748c390b8b2ad35a5abbab2bb5e8ae02048c2f081d77aeea548f34a0",
"imageSha256": "c4d5bd72e177b45737dbf1c3b09f52a31bb0c63829d8d652655cfb54f154c2ff"
},
"space-create": {
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"sourceSha256": "0626dfc9748c390b8b2ad35a5abbab2bb5e8ae02048c2f081d77aeea548f34a0",
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"sourceSha256": "0626dfc9748c390b8b2ad35a5abbab2bb5e8ae02048c2f081d77aeea548f34a0",
"imageSha256": "2f13ea645af306eee9cc7c52699c928377b72f412b08c1a3db6beb848ace1b51"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"sourceSha256": "0626dfc9748c390b8b2ad35a5abbab2bb5e8ae02048c2f081d77aeea548f34a0",
"imageSha256": "a6c526fede11bc3503fd2384bd4a3f6afa008f481c5c47578f06cae6c81c6f9b"
},
"device-editor": {
@@ -68,7 +68,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"sourceSha256": "0626dfc9748c390b8b2ad35a5abbab2bb5e8ae02048c2f081d77aeea548f34a0",
"imageSha256": "189287256dae5721cc8fac76b82180f1eba36281434032824aa616426fcad6d7"
},
"device-display-preview": {
@@ -79,8 +79,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"imageSha256": "112afb49df2f4398e5b3de29c21fd26af02fba007649e810786c97b3da42a0fd"
"sourceSha256": "0626dfc9748c390b8b2ad35a5abbab2bb5e8ae02048c2f081d77aeea548f34a0",
"imageSha256": "25c56f2e0754fd6746f7f16ed4e2c83b777d783f879c3158e38884103131653b"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"sourceSha256": "0626dfc9748c390b8b2ad35a5abbab2bb5e8ae02048c2f081d77aeea548f34a0",
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
},
"room-card": {
@@ -101,7 +101,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"sourceSha256": "0626dfc9748c390b8b2ad35a5abbab2bb5e8ae02048c2f081d77aeea548f34a0",
"imageSha256": "e7e3f0609a5dfa117e366b0388bd2d5286865110fd5798c02ffa752b2c285f95"
},
"device-info": {
@@ -112,7 +112,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"sourceSha256": "0626dfc9748c390b8b2ad35a5abbab2bb5e8ae02048c2f081d77aeea548f34a0",
"imageSha256": "b61541f0f53db632c99bf082d875ec1f84f77b534147b769e382ca54d8fe5c3e"
}
}
+1 -1
View File
@@ -16721,7 +16721,7 @@ class HouseplanCard extends LitElement {
const icon = d.marker?.icon || d.icon || 'mdi:robot-vacuum';
pucks.push(html`<div
data-mid="${d.id}"
class="vacpuck ${rt?.jump || jumpAll ? 'jump' : ''} ${stale ? 'stale' : ''}"
class="vacpuck ${deviceThemeClass(this._renderPlanHass)} ${rt?.jump || jumpAll ? 'jump' : ''} ${stale ? 'stale' : ''}"
style="left:${left}%;top:${top}%"
title=${d.name}
@click=${(e: Event) => { e.stopPropagation(); const ve = this._vacEntity(d); if (ve) this._openMoreInfo(ve); }}>
+11 -5
View File
@@ -2498,10 +2498,14 @@ export const cardStyles = css`
height: var(--puck-size);
border-radius: 50%;
transform: translate(-50%, -50%);
background: var(--hp-bg);
border: 1px solid var(--hp-line);
box-shadow: var(--shadow-1);
color: var(--hp-txt);
--vac-core-bg: light-dark(#fff, #252525);
--vac-core-fg: light-dark(#252525, #fff);
background: var(--vac-core-bg);
border: 1px solid #BCBCBC;
box-shadow:
0 1px 2px rgb(37 40 45 / 12%),
0 4px 8px -1.07px rgb(37 40 45 / 18%);
color: var(--vac-core-fg);
display: flex;
align-items: center;
justify-content: center;
@@ -2512,11 +2516,13 @@ export const cardStyles = css`
transition: left 1.2s linear, top 1.2s linear;
animation: vacpulse 2.2s ease-out infinite;
}
.vacpuck.theme-light { --vac-core-bg: #fff; --vac-core-fg: #252525; }
.vacpuck.theme-dark { --vac-core-bg: #252525; --vac-core-fg: #fff; }
.vacpuck.jump { transition: none; }
.vacpuck.stale { opacity: 0.45; animation: none; }
.vacpuck ha-icon {
--mdc-icon-size: calc(var(--puck-size) * 0.68);
color: var(--hp-txt);
color: var(--vac-core-fg);
/* same centering recipe as .dev ha-icon: without flex + line-height 0
the glyph sits on its text baseline and appears to float around the
circle (owner report 2026-07-31) */