Files
luma-home/cards/luma-vacuum-card.js
T
Kaloyan DanchevandClaude Opus 4.6 86191ba5f3
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s
Redesign panels, add 11 card fixes, new icons and app surfaces
Panel fixes: tall card row_span, room tab styling, vacuum card
image+value, AC airflow labels, AC fan speed sections (auto 3-btn
+ manual 5-step slider), light subtitle fallback, curtain pane
dividers, dimmable light presets, LED panel brightness + reorder.

New assets: 34 ui-icons (climate modes, cover, effects, battery,
led-strip, switch states), 3 logos, 90 app surface PNGs, design
system specs (components, ux-mechanics, tokens).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-27 23:29:32 +02:00

173 lines
7.3 KiB
JavaScript

const LUMA_VACUUM_VERSION = '0.2.0';
class LumaVacuumCard extends HTMLElement {
set hass(hass) { this._hass = hass; this._render(); }
setConfig(config) {
if (!config.entity) throw new Error('Entity is required');
this._config = { entity: config.entity, name: config.name || '', size: config.size || '1x2',
accent_color: config.accent_color || '', icon: config.icon || '',
image: config.image || '',
tap_action: config.tap_action || { action: 'more-info' },
hold_action: config.hold_action || { action: 'more-info' }, ...config };
this._render();
}
getCardSize() { return 4; }
static getStubConfig() { return { entity: '', name: '', size: '1x2' }; }
connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._render(); }
_getState() { return this._hass?.states?.[this._config?.entity]; }
_getPrefix() {
return this._config?.entity?.replace(/^vacuum\./, '') || '';
}
_getSensorState(suffix) {
const id = `sensor.${this._getPrefix()}_${suffix}`;
return this._hass?.states?.[id];
}
_callService(service, data = {}) {
if (!this._hass || !this._config) return;
this._hass.callService('vacuum', service, { entity_id: this._config.entity, ...data });
}
_handleClick() {
if (!this._hass || !this._config) return;
const ev = new Event('hass-more-info', { composed: true });
ev.detail = { entityId: this._config.entity };
this.dispatchEvent(ev);
}
_render() {
if (!this.shadowRoot || !this._config) return;
const s = this._getState();
const prefix = this._getPrefix();
const statusSensor = this._getSensorState('status');
const statusText = statusSensor?.state || s?.state || 'unavailable';
const isCleaning = ['cleaning', 'segment_cleaning', 'zoned_cleaning', 'spot_cleaning',
'segment_mopping', 'robot_status_mopping'].includes(statusText);
const isReturning = ['returning_home', 'docking'].includes(statusText);
const errorSensor = this._getSensorState('vacuum_error');
const hasError = errorSensor && errorSensor.state !== 'none' && errorSensor.state !== '';
const batterySensor = this._getSensorState('battery');
const battery = batterySensor ? parseInt(batterySensor.state) || 0 : 0;
const accent = hasError ? '#e05050'
: isCleaning ? '#7EB8DA'
: isReturning ? '#f0a060'
: 'rgba(157,170,184,0.7)';
const displayStatus = {
charging: 'Charged', charging_complete: 'Charged',
cleaning: 'Cleaning', segment_cleaning: 'Cleaning', zoned_cleaning: 'Cleaning',
returning_home: 'Returning', docking: 'Returning',
washing_the_mop: 'Washing mop', going_to_wash_the_mop: 'Washing mop',
emptying_the_bin: 'Emptying bin',
docked: 'Docked', idle: 'Idle', paused: 'Paused', error: 'Error',
}[statusText] || statusText;
const statusLine = hasError
? `⚠ ${errorSensor.state.replace(/_/g, ' ')} · ${battery}%`
: `${displayStatus} · ${battery}%`;
const statusColor = hasError ? '#e05050' : isCleaning ? accent : 'rgba(232,232,232,0.50)';
const name = this._config.name || s?.attributes?.friendly_name || '';
const glowHtml = isCleaning
? '<div class="glow glow-green"></div>'
: hasError
? '<div class="glow glow-red"></div>'
: '';
const actions = ['Dock', 'Mop Only', 'V&M Fast', 'After Meals', 'Vac & Mop', 'Custom'];
const actionsHtml = actions.map((label, i) => {
const cls = i === 5 ? 'action-btn custom' : 'action-btn';
return `<button class="${cls}" data-idx="${i}">${label}</button>`;
}).join('');
this.shadowRoot.innerHTML = `
<style>
:host { display: block; --accent: ${accent}; }
.card {
position: relative; overflow: hidden; background: rgba(36,40,48,0.75);
border: 1px solid rgba(255,255,255,0.10); border-radius: 22px;
height: 100%; min-height: 280px; display: flex; flex-direction: column;
box-shadow: 0 2px 6px rgba(0,0,0,.06); padding: 16px;
transition: border-color .15s, box-shadow .15s;
}
.card:hover { border-color: rgba(255,255,255,0.18); box-shadow: 0 8px 28px rgba(0,0,0,.28); }
.glow {
position: absolute; top: -20px; right: -20px; width: 200px; height: 200px;
border-radius: 50%; pointer-events: none; filter: blur(40px);
}
.glow-green { background: rgba(80,224,112,0.35); }
.glow-red { background: rgba(224,80,80,0.35); }
.header { position: relative; z-index: 1; }
.name { font-size: 18px; font-weight: 600; letter-spacing: -0.02em; color: #fff; }
.status { font-size: 13px; font-weight: 400; letter-spacing: 0.02em; color: ${statusColor}; margin-top: 4px; }
.product-img {
position: absolute; top: 16px; right: 16px; width: 160px; height: 160px;
object-fit: contain; z-index: 1; pointer-events: none;
}
.product-img-placeholder {
position: absolute; top: 16px; right: 16px; width: 160px; height: 160px;
display: flex; align-items: center; justify-content: center; z-index: 1;
}
.product-img-placeholder ha-icon { --mdc-icon-size: 80px; color: rgba(187,187,187,0.2); }
.actions {
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;
margin-top: auto; position: relative; z-index: 1;
}
.action-btn {
background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px; color: rgba(232,232,232,0.80); font-size: 13px; font-weight: 500;
padding: 14px 8px; cursor: pointer; transition: all .15s; text-align: center;
}
.action-btn:hover { background: rgba(126,184,218,0.20); border-color: rgba(126,184,218,0.5); color: #fff; }
.action-btn.custom { color: rgba(232,232,232,0.55); background: rgba(255,255,255,0.04); }
.action-btn.custom:hover { background: rgba(255,255,255,0.10); color: rgba(232,232,232,0.80); }
</style>
<div class="card">
${glowHtml}
<div class="header">
<div class="name">${name}</div>
<div class="status">${statusLine}</div>
</div>
${this._config.image
? `<img class="product-img" src="${this._config.image}" alt="" />`
: `<div class="product-img-placeholder"><ha-icon icon="mdi:robot-vacuum"></ha-icon></div>`}
<div class="actions">${actionsHtml}</div>
</div>`;
this.shadowRoot.querySelector('.header').addEventListener('click', () => this._handleClick());
this.shadowRoot.querySelectorAll('.action-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx);
switch (idx) {
case 0: this._callService('return_to_base'); break;
case 5: this._handleClick(); break;
default: this._handleClick(); break;
}
});
});
}
}
customElements.define('luma-vacuum-card', LumaVacuumCard);
window.customCards = window.customCards || [];
window.customCards.push({ type: 'luma-vacuum-card', name: 'Luma Vacuum', description: 'Luma-style vacuum card with product image, battery, status, and 6-button action grid.', preview: true });
console.info(`%c LUMA-VACUUM-CARD %c v${LUMA_VACUUM_VERSION} `, 'background:#7EB8DA;color:#111;font-weight:bold', 'background:#161a22;color:#7EB8DA');