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>
173 lines
7.3 KiB
JavaScript
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');
|