Add HACS integration, 8 Lovelace cards, and pairing page
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

New HA integration (integration/custom_components/luma_home/) with:
- WS API: luma/get_config (Lovelace→v2 bridge), luma/pair, luma/pair_status
- Config flow with dashboard URL path setting

8 HACS frontend cards (cards/) matching TV app visual spec:
- toggle, range, climate, media, curtain, action, vacuum, weather
- Glass card design: rgba(36,40,48,0.75) bg, 22px radius, accent glows

Pairing page (pairing/luma-pair.html) for TV token provisioning.
Web dashboard (web-dashboard/) — legacy config editor (deprecated).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kaloyan DanchevandClaude Opus 4.6 committed 2026-10-25 13:19:26 +02:00
1 parent a890ba6baf
commit d681f20884
18 files changed
+2708

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
const LUMA_ACTION_VERSION = '0.1.0';
class LumaActionCard 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 || '1x1',
accent_color: config.accent_color || '', icon: config.icon || '',
tap_action: config.tap_action || { action: 'toggle' },
hold_action: config.hold_action || { action: 'more-info' },
confirmation: config.confirmation || false, ...config };
this._render();
}
getCardSize() { return 2; }
static getStubConfig() { return { entity: '', name: '', size: '1x1' }; }
connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._render(); }
_getState() { return this._hass?.states?.[this._config?.entity]; }
_handleClick() {
if (!this._hass || !this._config) return;
const entity = this._config.entity;
if (entity.startsWith('scene.')) {
this._hass.callService('scene', 'turn_on', { entity_id: entity });
} else if (entity.startsWith('script.')) {
this._hass.callService('script', 'turn_on', { entity_id: entity });
} else {
this._hass.callService('homeassistant', 'toggle', { entity_id: entity });
}
}
_render() {
if (!this.shadowRoot || !this._config) return;
const accent = this._config.accent_color || '#c9a0dc';
const name = this._config.name || this._getState()?.attributes?.friendly_name || '';
const icon = this._config.icon || 'mdi:play-circle';
const typeLabel = this._config.entity.startsWith('script.') ? 'Script' : 'Scene';
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; padding: 16px;
cursor: pointer; transition: border-color .15s, box-shadow .15s;
height: 100%; min-height: 120px; display: flex; flex-direction: column;
justify-content: space-between; box-shadow: 0 2px 6px rgba(0,0,0,.06);
}
.card:hover { border-color: rgba(255,255,255,0.18); box-shadow: 0 8px 28px rgba(0,0,0,.28); }
.top { display: flex; align-items: center; gap: 12px; position: relative; z-index: 1; }
.icon-circle {
width: 48px; height: 48px; border-radius: 24px; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
background: color-mix(in srgb, var(--accent) 15%, transparent);
}
.icon-circle ha-icon { --mdc-icon-size: 28px; color: var(--accent); }
.info { flex: 1; min-width: 0; }
.type-label { font-size: 12px; font-weight: 400; letter-spacing: 0.04em; color: color-mix(in srgb, var(--accent) 70%, transparent); }
.name { font-size: 16px; font-weight: 600; letter-spacing: -0.02em; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.hint { font-size: 12px; font-weight: 400; letter-spacing: 0.02em; color: rgba(232,232,232,0.40); position: relative; z-index: 1; }
</style>
<div class="card">
<div class="top">
<div class="icon-circle"><ha-icon icon="${icon}"></ha-icon></div>
<div class="info">
<div class="type-label">${typeLabel}</div>
<div class="name">${name}</div>
</div>
</div>
<div class="hint">Tap to activate</div>
</div>`;
this.shadowRoot.querySelector('.card').addEventListener('click', () => this._handleClick());
}
}
customElements.define('luma-action-card', LumaActionCard);
window.customCards = window.customCards || [];
window.customCards.push({ type: 'luma-action-card', name: 'Luma Action', description: 'Luma-style scene/script activation card.', preview: true });
console.info(`%c LUMA-ACTION-CARD %c v${LUMA_ACTION_VERSION} `, 'background:#c9a0dc;color:#111;font-weight:bold', 'background:#161a22;color:#c9a0dc');
+8
View File
@@ -0,0 +1,8 @@
import './luma-toggle-card.js';
import './luma-range-card.js';
import './luma-climate-card.js';
import './luma-media-card.js';
import './luma-curtain-card.js';
import './luma-action-card.js';
import './luma-vacuum-card.js';
import './luma-weather-card.js';
+120
View File
@@ -0,0 +1,120 @@
const LUMA_CLIMATE_VERSION = '0.1.0';
class LumaClimateCard 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 || '1x1',
accent_color: config.accent_color || '', icon: config.icon || '',
tap_action: config.tap_action || { action: 'more-info' },
hold_action: config.hold_action || { action: 'more-info' }, ...config };
this._render();
}
getCardSize() { return (this._config?.size === '1x2' || this._config?.size === '2x2') ? 4 : 2; }
static getStubConfig() { return { entity: '', name: '', size: '1x1' }; }
connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._render(); }
_getState() { return this._hass?.states?.[this._config?.entity]; }
_getAccent() {
if (this._config?.accent_color) return this._config.accent_color;
const s = this._getState();
if (!s) return '#bbbbbb';
switch (s.state) {
case 'heat': case 'heat_cool': case 'auto': return '#f0a060';
case 'cool': return '#60b0f0';
default: return '#bbbbbb';
}
}
_getFillColor() {
const s = this._getState();
if (!s) return 'transparent';
switch (s.state) {
case 'heat': case 'heat_cool': case 'auto': return 'rgba(240,96,64,0.28)';
case 'cool': return 'rgba(64,144,240,0.28)';
default: return 'rgba(240,160,96,0.15)';
}
}
_isActive() {
const s = this._getState();
return s && s.state !== 'off' && s.state !== 'unavailable';
}
_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 isActive = this._isActive();
const accent = this._getAccent();
const fill = this._getFillColor();
const name = this._config.name || s?.attributes?.friendly_name || '';
const icon = this._config.icon || 'mdi:thermometer';
const currentTemp = s?.attributes?.current_temperature ?? '--';
const targetTemp = s?.attributes?.temperature ?? '--';
const hvac = s?.state || 'off';
const modeLabel = { heat: 'Heat', cool: 'Cool', heat_cool: 'Auto', auto: 'Auto', off: 'Off' }[hvac] || hvac;
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; padding: 16px;
cursor: pointer; transition: border-color .15s, box-shadow .15s;
height: 100%; min-height: 140px; display: flex; flex-direction: column;
justify-content: space-between; box-shadow: 0 2px 6px rgba(0,0,0,.06);
}
.card:hover { border-color: rgba(255,255,255,0.18); box-shadow: 0 8px 28px rgba(0,0,0,.28); }
.card.active { border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
.glow {
position: absolute; top: -20px; left: -20px; width: 200px; height: 200px;
border-radius: 50%; pointer-events: none; opacity: 0; transition: opacity .2s;
background: radial-gradient(circle, ${fill} 0%, transparent 70%);
}
.card.active .glow { opacity: 1; }
.top { display: flex; align-items: center; gap: 12px; position: relative; z-index: 1; }
.icon-circle {
width: 48px; height: 48px; border-radius: 24px; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
background: ${isActive ? `color-mix(in srgb, var(--accent) 20%, transparent)` : 'rgba(255,255,255,0.08)'};
}
.icon-circle ha-icon { --mdc-icon-size: 28px; color: var(--accent); }
.info { flex: 1; min-width: 0; }
.mode-label { font-size: 12px; font-weight: 400; letter-spacing: 0.04em; color: ${isActive ? 'var(--accent)' : 'rgba(232,232,232,0.45)'}; }
.name { font-size: 16px; font-weight: 600; letter-spacing: -0.02em; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.bottom { position: relative; z-index: 1; }
.now { font-size: 12px; font-weight: 400; letter-spacing: 0.02em; color: rgba(232,232,232,0.55); margin-bottom: 2px; }
.target { font-size: 24px; font-weight: 700; letter-spacing: -0.02em; color: ${isActive ? '#fff' : 'rgba(187,187,187,0.5)'}; }
</style>
<div class="card ${isActive ? 'active' : ''}">
<div class="glow"></div>
<div class="top">
<div class="icon-circle"><ha-icon icon="${icon}"></ha-icon></div>
<div class="info">
<div class="mode-label">${modeLabel}</div>
<div class="name">${name}</div>
</div>
</div>
<div class="bottom">
<div class="now">Now ${currentTemp}°C</div>
<div class="target">${targetTemp}°</div>
</div>
</div>`;
this.shadowRoot.querySelector('.card').addEventListener('click', () => this._handleClick());
}
}
customElements.define('luma-climate-card', LumaClimateCard);
window.customCards = window.customCards || [];
window.customCards.push({ type: 'luma-climate-card', name: 'Luma Climate', description: 'Luma-style climate/thermostat card with temperature display.', preview: true });
console.info(`%c LUMA-CLIMATE-CARD %c v${LUMA_CLIMATE_VERSION} `, 'background:#f0a060;color:#111;font-weight:bold', 'background:#161a22;color:#f0a060');
+100
View File
@@ -0,0 +1,100 @@
const LUMA_CURTAIN_VERSION = '0.1.0';
class LumaCurtainCard 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 || '1x1',
accent_color: config.accent_color || '', icon: config.icon || '',
tap_action: config.tap_action || { action: 'more-info' },
hold_action: config.hold_action || { action: 'more-info' }, ...config };
this._render();
}
getCardSize() { return 2; }
static getStubConfig() { return { entity: '', name: '', size: '1x1' }; }
connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._render(); }
_getState() { return this._hass?.states?.[this._config?.entity]; }
_getPosition() {
const s = this._getState();
return parseFloat(s?.attributes?.current_position) || 0;
}
_isOpen() { return this._getPosition() > 5; }
_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 pos = this._getPosition();
const isOpen = this._isOpen();
const name = this._config.name || this._getState()?.attributes?.friendly_name || '';
const icon = this._config.icon || 'mdi:curtains';
const accent = isOpen ? '#dcd2b4' : 'rgba(187,187,187,0.5)';
const label = pos > 0 ? `${Math.round(pos)}%` : 'Closed';
const closedPct = 1 - Math.min(pos / 100, 1);
const closedOverlay = closedPct > 0.05 ? `rgba(12,16,36,${(0.5 * closedPct).toFixed(2)})` : 'transparent';
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; padding: 16px;
cursor: pointer; transition: border-color .15s, box-shadow .15s;
height: 100%; min-height: 120px; display: flex; flex-direction: column;
justify-content: space-between; box-shadow: 0 2px 6px rgba(0,0,0,.06);
}
.card:hover { border-color: rgba(255,255,255,0.18); box-shadow: 0 8px 28px rgba(0,0,0,.28); }
.card.open { border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
.closed-overlay {
position: absolute; inset: 0; border-radius: 22px;
background: ${closedOverlay}; pointer-events: none; z-index: 0;
}
.glow {
position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
width: 200px; height: 200px; border-radius: 50%; pointer-events: none;
opacity: 0; transition: opacity .2s;
background: radial-gradient(circle, rgba(220,210,180,0.20) 0%, transparent 70%);
}
.card.open .glow { opacity: 1; }
.top { display: flex; align-items: center; gap: 12px; position: relative; z-index: 1; }
.icon-circle {
width: 48px; height: 48px; border-radius: 24px; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
background: ${isOpen ? 'color-mix(in srgb, var(--accent) 20%, transparent)' : 'rgba(255,255,255,0.08)'};
}
.icon-circle ha-icon { --mdc-icon-size: 28px; color: var(--accent); }
.info { flex: 1; min-width: 0; }
.type-label { font-size: 12px; font-weight: 400; letter-spacing: 0.04em; color: ${isOpen ? 'var(--accent)' : 'rgba(232,232,232,0.45)'}; }
.name { font-size: 16px; font-weight: 600; letter-spacing: -0.02em; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.status { font-size: 20px; font-weight: 700; letter-spacing: -0.02em; color: ${isOpen ? '#fff' : 'rgba(187,187,187,0.5)'}; position: relative; z-index: 1; }
</style>
<div class="card ${isOpen ? 'open' : ''}">
<div class="closed-overlay"></div>
<div class="glow"></div>
<div class="top">
<div class="icon-circle"><ha-icon icon="${icon}"></ha-icon></div>
<div class="info">
<div class="type-label">Curtain</div>
<div class="name">${name}</div>
</div>
</div>
<div class="status">${label}</div>
</div>`;
this.shadowRoot.querySelector('.card').addEventListener('click', () => this._handleClick());
}
}
customElements.define('luma-curtain-card', LumaCurtainCard);
window.customCards = window.customCards || [];
window.customCards.push({ type: 'luma-curtain-card', name: 'Luma Curtain', description: 'Luma-style cover/curtain card with position display.', preview: true });
console.info(`%c LUMA-CURTAIN-CARD %c v${LUMA_CURTAIN_VERSION} `, 'background:#dcd2b4;color:#111;font-weight:bold', 'background:#161a22;color:#dcd2b4');
+106
View File
@@ -0,0 +1,106 @@
const LUMA_MEDIA_VERSION = '0.1.0';
class LumaMediaCard 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 || '',
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]; }
_isActive() {
const s = this._getState();
return s && (s.state === 'playing' || s.state === 'paused');
}
_getAccent() {
if (this._config?.accent_color) return this._config.accent_color;
const s = this._getState();
if (!s) return '#bbbbbb';
switch (s.state) {
case 'playing': return '#9b7ddb';
case 'paused': return 'rgba(125,155,219,0.7)';
default: return 'rgba(187,187,187,0.5)';
}
}
_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 isActive = this._isActive();
const accent = this._getAccent();
const icon = this._config.icon || 'mdi:music';
const title = s?.attributes?.media_title || this._config.name || s?.attributes?.friendly_name || '';
const artist = s?.attributes?.media_artist || '';
const volume = Math.round((s?.attributes?.volume_level || 0) * 100);
const state = s?.state || 'off';
const statusLabel = { playing: 'Playing', paused: 'Paused', idle: 'Idle' }[state] || 'Off';
const volLabel = isActive ? `Vol ${volume}%` : 'Off';
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; padding: 16px;
cursor: pointer; transition: border-color .15s, box-shadow .15s;
height: 100%; min-height: 240px; display: flex; flex-direction: column;
align-items: center; box-shadow: 0 2px 6px rgba(0,0,0,.06);
}
.card:hover { border-color: rgba(255,255,255,0.18); box-shadow: 0 8px 28px rgba(0,0,0,.28); }
.card.active { border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
.glow {
position: absolute; top: 0; left: 50%; transform: translateX(-50%);
width: 200px; height: 200px; border-radius: 50%; pointer-events: none;
opacity: 0; transition: opacity .2s;
background: radial-gradient(circle, rgba(128,96,192,0.25) 0%, transparent 70%);
}
.card.active .glow { opacity: 1; }
.art {
width: 120px; height: 120px; border-radius: 60px; margin-top: 8px;
display: flex; align-items: center; justify-content: center;
background: ${isActive ? 'color-mix(in srgb, var(--accent) 25%, transparent)' : 'rgba(255,255,255,0.08)'};
position: relative; z-index: 1;
}
.art ha-icon { --mdc-icon-size: 60px; color: ${isActive ? 'var(--accent)' : 'rgba(187,187,187,0.4)'}; }
.text-area { text-align: center; margin-top: 16px; width: 100%; padding: 0 8px; position: relative; z-index: 1; flex: 1; }
.title { font-size: 17px; font-weight: 600; letter-spacing: -0.02em; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.artist { font-size: 13px; font-weight: 400; color: rgba(232,232,232,0.6); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.state-label { font-size: 11px; font-weight: 400; letter-spacing: 0.04em; color: ${isActive ? 'var(--accent)' : 'rgba(232,232,232,0.45)'}; margin-top: 8px; }
.vol { font-size: 13px; font-weight: 500; color: ${isActive ? '#fff' : 'rgba(187,187,187,0.5)'}; text-align: center; padding-bottom: 4px; position: relative; z-index: 1; }
</style>
<div class="card ${isActive ? 'active' : ''}">
<div class="glow"></div>
<div class="art"><ha-icon icon="${icon}"></ha-icon></div>
<div class="text-area">
<div class="title">${title}</div>
${artist ? `<div class="artist">${artist}</div>` : ''}
<div class="state-label">${statusLabel}</div>
</div>
<div class="vol">${volLabel}</div>
</div>`;
this.shadowRoot.querySelector('.card').addEventListener('click', () => this._handleClick());
}
}
customElements.define('luma-media-card', LumaMediaCard);
window.customCards = window.customCards || [];
window.customCards.push({ type: 'luma-media-card', name: 'Luma Media', description: 'Luma-style media player card with album art placeholder.', preview: true });
console.info(`%c LUMA-MEDIA-CARD %c v${LUMA_MEDIA_VERSION} `, 'background:#9b7ddb;color:#111;font-weight:bold', 'background:#161a22;color:#9b7ddb');
+122
View File
@@ -0,0 +1,122 @@
const LUMA_RANGE_VERSION = '0.1.0';
class LumaRangeCard 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 || '1x1',
accent_color: config.accent_color || '', glow_color: config.glow_color || '',
icon: config.icon || '', tap_action: config.tap_action || { action: 'more-info' },
hold_action: config.hold_action || { action: 'more-info' }, confirmation: config.confirmation || false, ...config };
this._render();
}
getCardSize() { return (this._config?.size === '1x2' || this._config?.size === '2x2') ? 4 : 2; }
static getStubConfig() { return { entity: '', name: '', size: '1x1' }; }
connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._render(); }
_getState() { return this._hass?.states?.[this._config?.entity]; }
_isOn() { const s = this._getState(); return s && s.state === 'on'; }
_getBrightness() {
const s = this._getState();
if (!s || s.state !== 'on') return 0;
const b = parseFloat(s.attributes?.brightness);
return isNaN(b) ? 0 : Math.round(b / 255 * 100);
}
_getAccent() {
if (this._config?.accent_color) return this._config.accent_color;
const s = this._getState();
if (s?.attributes?.rgb_color) {
const [r, g, b] = s.attributes.rgb_color;
return `rgb(${r},${g},${b})`;
}
return this._isOn() ? '#fab95c' : '#bbbbbb';
}
_getGlow() {
if (this._config?.glow_color) return this._config.glow_color;
const s = this._getState();
if (s?.attributes?.rgb_color) {
const [r, g, b] = s.attributes.rgb_color;
return `rgb(${r},${g},${b})`;
}
return '#fab95c';
}
_handleClick() {
if (!this._hass || !this._config) return;
const action = this._config.tap_action?.action || 'more-info';
if (action === 'toggle') {
this._hass.callService('homeassistant', 'toggle', { entity_id: this._config.entity });
} else {
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 isOn = this._isOn();
const pct = this._getBrightness();
const accent = this._getAccent();
const glow = this._getGlow();
const name = this._config.name || this._getState()?.attributes?.friendly_name || '';
const icon = this._config.icon || 'mdi:lightbulb';
const label = isOn ? `${pct}%` : 'Off';
const glowAlpha = isOn ? (pct / 100 * 0.35).toFixed(2) : '0';
this.shadowRoot.innerHTML = `
<style>
:host { display: block; --accent: ${accent}; --glow: ${glow}; }
.card {
position: relative; overflow: hidden; background: rgba(36,40,48,0.75);
border: 1px solid rgba(255,255,255,0.10); border-radius: 22px; padding: 16px;
cursor: pointer; transition: border-color .15s, box-shadow .15s;
height: 100%; min-height: 120px; display: flex; flex-direction: column;
justify-content: space-between; box-shadow: 0 2px 6px rgba(0,0,0,.06);
}
.card:hover { border-color: rgba(255,255,255,0.18); box-shadow: 0 8px 28px rgba(0,0,0,.28); }
.card.on { border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
.glow {
position: absolute; top: -20px; left: -20px; width: 200px; height: 200px;
border-radius: 50%; pointer-events: none; opacity: 0; transition: opacity .2s;
background: radial-gradient(circle, color-mix(in srgb, var(--glow) ${Math.round(glowAlpha * 100)}%, transparent) 0%, transparent 70%);
}
.card.on .glow { opacity: 1; }
.top { display: flex; align-items: center; gap: 12px; position: relative; z-index: 1; }
.icon-circle {
width: 48px; height: 48px; border-radius: 24px; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
background: ${isOn ? `color-mix(in srgb, var(--accent) 20%, transparent)` : 'rgba(255,255,255,0.08)'};
transition: background .15s;
}
.icon-circle ha-icon { --mdc-icon-size: 28px; color: ${isOn ? 'var(--accent)' : 'rgba(187,187,187,0.5)'}; }
.info { flex: 1; min-width: 0; }
.type-label { font-size: 12px; font-weight: 400; letter-spacing: 0.04em; color: ${isOn ? 'var(--accent)' : 'rgba(232,232,232,0.45)'}; }
.name { font-size: 16px; font-weight: 600; letter-spacing: -0.02em; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.status { font-size: 20px; font-weight: 700; letter-spacing: -0.02em; color: ${isOn ? '#fff' : 'rgba(187,187,187,0.5)'}; position: relative; z-index: 1; }
</style>
<div class="card ${isOn ? 'on' : ''}">
<div class="glow"></div>
<div class="top">
<div class="icon-circle"><ha-icon icon="${icon}"></ha-icon></div>
<div class="info">
<div class="type-label">Light</div>
<div class="name">${name}</div>
</div>
</div>
<div class="status">${label}</div>
</div>`;
this.shadowRoot.querySelector('.card').addEventListener('click', () => this._handleClick());
}
}
customElements.define('luma-range-card', LumaRangeCard);
window.customCards = window.customCards || [];
window.customCards.push({ type: 'luma-range-card', name: 'Luma Range', description: 'Luma-style dimmable light card with brightness control.', preview: true });
console.info(`%c LUMA-RANGE-CARD %c v${LUMA_RANGE_VERSION} `, 'background:#fab95c;color:#111;font-weight:bold', 'background:#161a22;color:#fab95c');
+269
View File
@@ -0,0 +1,269 @@
/**
* Luma Toggle Card — Custom HA Lovelace card
* Matches the TV app's toggle card visual spec.
*/
const LUMA_CARD_VERSION = '0.1.0';
class LumaToggleCard extends HTMLElement {
static get properties() {
return { hass: {}, config: {} };
}
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 || '1x1',
accent_color: config.accent_color || '',
glow_color: config.glow_color || '',
icon: config.icon || '',
tap_action: config.tap_action || { action: 'toggle' },
hold_action: config.hold_action || { action: 'more-info' },
confirmation: config.confirmation || false,
...config,
};
this._render();
}
getCardSize() {
const size = this._config?.size || '1x1';
return size === '1x2' || size === '2x2' ? 4 : 2;
}
static getConfigElement() {
return document.createElement('luma-card-editor');
}
static getStubConfig() {
return { entity: '', name: '', size: '1x1' };
}
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
}
this._render();
}
_getState() {
if (!this._hass || !this._config) return null;
return this._hass.states[this._config.entity];
}
_isOn() {
const state = this._getState();
return state && (state.state === 'on' || state.state === 'open' || state.state === 'unlocked');
}
_getAccentColor() {
if (this._config?.accent_color) return this._config.accent_color;
const state = this._getState();
if (!state) return '#bbbbbb';
switch (state.state) {
case 'on': case 'open': return '#7EB8DA';
case 'heat': case 'heat_cool': case 'auto': return '#f0a060';
case 'cool': return '#60b0f0';
default: return '#bbbbbb';
}
}
_getGlowColor() {
if (this._config?.glow_color) return this._config.glow_color;
const state = this._getState();
if (state?.attributes?.rgb_color) {
const [r, g, b] = state.attributes.rgb_color;
return `rgb(${r}, ${g}, ${b})`;
}
return '#fab95c';
}
_getTypeLabel() {
const entity = this._config?.entity || '';
if (entity.startsWith('light.')) return 'Light';
if (entity.startsWith('cover.')) return 'Cover';
if (entity.startsWith('switch.')) return 'Switch';
if (entity.startsWith('lock.')) return 'Lock';
return 'Toggle';
}
_getStatusText() {
const state = this._getState();
if (!state) return 'Unavailable';
const map = { on: 'On', off: 'Off', open: 'Open', closed: 'Closed', locked: 'Locked', unlocked: 'Unlocked' };
return map[state.state] || state.state;
}
_getIcon() {
if (this._config?.icon) return this._config.icon;
const entity = this._config?.entity || '';
if (entity.startsWith('light.')) return 'mdi:lightbulb';
if (entity.startsWith('cover.')) return 'mdi:curtains';
if (entity.startsWith('switch.')) return 'mdi:power-plug';
if (entity.startsWith('lock.')) return 'mdi:lock';
return 'mdi:toggle-switch';
}
_handleClick() {
if (!this._hass || !this._config) return;
const action = this._config.tap_action?.action || 'toggle';
if (action === 'toggle') {
this._hass.callService('homeassistant', 'toggle', { entity_id: this._config.entity });
} else if (action === 'more-info') {
const event = new Event('hass-more-info', { composed: true });
event.detail = { entityId: this._config.entity };
this.dispatchEvent(event);
}
}
_render() {
if (!this.shadowRoot || !this._config) return;
const isOn = this._isOn();
const accent = this._getAccentColor();
const glow = this._getGlowColor();
const typeLabel = this._getTypeLabel();
const name = this._config.name || this._getState()?.attributes?.friendly_name || this._config.entity.split('.')[1] || '';
const status = this._getStatusText();
const icon = this._getIcon();
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
--accent: ${accent};
--glow: ${glow};
}
.card {
position: relative;
overflow: hidden;
background: rgba(36, 40, 48, 0.75);
border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 22px;
padding: 16px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
height: 100%;
min-height: 120px;
display: flex;
flex-direction: column;
justify-content: space-between;
box-shadow: 0 2px 6px rgba(0,0,0,0.06);
}
.card:hover {
border-color: rgba(255, 255, 255, 0.18);
box-shadow: 0 8px 28px rgba(0,0,0,0.28);
}
.card.on {
border-color: color-mix(in srgb, var(--accent) 25%, transparent);
}
.glow {
position: absolute;
top: -20px;
left: -20px;
width: 200px;
height: 200px;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in srgb, var(--glow) 25%, transparent) 0%, transparent 70%);
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
}
.card.on .glow { opacity: 1; }
.top {
display: flex;
align-items: center;
gap: 12px;
position: relative;
z-index: 1;
}
.icon-circle {
width: 48px;
height: 48px;
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.08);
flex-shrink: 0;
transition: background 0.15s;
}
.card.on .icon-circle {
background: color-mix(in srgb, var(--accent) 20%, transparent);
}
.icon-circle ha-icon {
--mdc-icon-size: 28px;
color: rgba(187, 187, 187, 0.5);
transition: color 0.15s;
}
.card.on .icon-circle ha-icon {
color: var(--accent);
}
.info {
flex: 1;
min-width: 0;
}
.type-label {
font-size: 12px;
font-weight: 400;
letter-spacing: 0.04em;
color: rgba(232, 232, 232, 0.45);
transition: color 0.15s;
}
.card.on .type-label { color: var(--accent); }
.name {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.02em;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.status {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.02em;
color: rgba(187, 187, 187, 0.5);
position: relative;
z-index: 1;
}
.card.on .status { color: #fff; }
</style>
<div class="card ${isOn ? 'on' : ''}" @click="${this._handleClick}">
<div class="glow"></div>
<div class="top">
<div class="icon-circle">
<ha-icon icon="${icon}"></ha-icon>
</div>
<div class="info">
<div class="type-label">${typeLabel}</div>
<div class="name">${name}</div>
</div>
</div>
<div class="status">${status}</div>
</div>
`;
this.shadowRoot.querySelector('.card').addEventListener('click', () => this._handleClick());
}
}
customElements.define('luma-toggle-card', LumaToggleCard);
window.customCards = window.customCards || [];
window.customCards.push({
type: 'luma-toggle-card',
name: 'Luma Toggle',
description: 'Luma-style toggle card for lights, switches, and binary entities.',
preview: true,
});
console.info(`%c LUMA-TOGGLE-CARD %c v${LUMA_CARD_VERSION} `, 'background:#7EB8DA;color:#111;font-weight:bold', 'background:#161a22;color:#7EB8DA');
+104
View File
@@ -0,0 +1,104 @@
const LUMA_VACUUM_VERSION = '0.1.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 || '1x1',
accent_color: config.accent_color || '', icon: config.icon || '',
tap_action: config.tap_action || { action: 'more-info' },
hold_action: config.hold_action || { action: 'more-info' }, ...config };
this._render();
}
getCardSize() { return 2; }
static getStubConfig() { return { entity: '', name: '', size: '1x1' }; }
connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._render(); }
_getState() { return this._hass?.states?.[this._config?.entity]; }
_getAccent() {
if (this._config?.accent_color) return this._config.accent_color;
const s = this._getState();
if (!s) return 'rgba(187,187,187,0.5)';
switch (s.state) {
case 'cleaning': return '#7EB8DA';
case 'returning': return '#f0a060';
default: return 'rgba(187,187,187,0.5)';
}
}
_isActive() {
const s = this._getState();
return s && (s.state === 'cleaning' || s.state === 'returning');
}
_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 isActive = this._isActive();
const accent = this._getAccent();
const name = this._config.name || s?.attributes?.friendly_name || '';
const icon = this._config.icon || 'mdi:robot-vacuum';
const status = s?.state || 'unavailable';
const displayStatus = { cleaning: 'Cleaning', returning: 'Returning', docked: 'Docked', idle: 'Idle', paused: 'Paused' }[status] || status;
const fillColor = status === 'cleaning' ? 'rgba(126,184,218,0.25)' : status === 'returning' ? 'rgba(240,160,96,0.20)' : 'transparent';
const fillPct = status === 'cleaning' ? 60 : status === 'returning' ? 30 : 0;
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; padding: 16px;
cursor: pointer; transition: border-color .15s, box-shadow .15s;
height: 100%; min-height: 120px; display: flex; flex-direction: column;
justify-content: space-between; box-shadow: 0 2px 6px rgba(0,0,0,.06);
}
.card:hover { border-color: rgba(255,255,255,0.18); box-shadow: 0 8px 28px rgba(0,0,0,.28); }
.card.active { border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
.fill {
position: absolute; left: 0; top: 0; bottom: 0; border-radius: 22px;
width: ${fillPct}%; background: ${fillColor}; pointer-events: none;
transition: width .3s;
}
.top { display: flex; align-items: center; gap: 12px; position: relative; z-index: 1; }
.icon-circle {
width: 48px; height: 48px; border-radius: 24px; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
background: ${isActive ? 'color-mix(in srgb, var(--accent) 20%, transparent)' : 'rgba(255,255,255,0.08)'};
}
.icon-circle ha-icon { --mdc-icon-size: 28px; color: var(--accent); }
.info { flex: 1; min-width: 0; }
.type-label { font-size: 12px; font-weight: 400; letter-spacing: 0.04em; color: ${isActive ? 'var(--accent)' : 'rgba(232,232,232,0.45)'}; }
.name { font-size: 16px; font-weight: 600; letter-spacing: -0.02em; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.status { font-size: 20px; font-weight: 700; letter-spacing: -0.02em; color: ${isActive ? '#fff' : 'rgba(187,187,187,0.5)'}; position: relative; z-index: 1; }
</style>
<div class="card ${isActive ? 'active' : ''}">
<div class="fill"></div>
<div class="top">
<div class="icon-circle"><ha-icon icon="${icon}"></ha-icon></div>
<div class="info">
<div class="type-label">Vacuum</div>
<div class="name">${name}</div>
</div>
</div>
<div class="status">${displayStatus}</div>
</div>`;
this.shadowRoot.querySelector('.card').addEventListener('click', () => this._handleClick());
}
}
customElements.define('luma-vacuum-card', LumaVacuumCard);
window.customCards = window.customCards || [];
window.customCards.push({ type: 'luma-vacuum-card', name: 'Luma Vacuum', description: 'Luma-style robot vacuum card with status display.', preview: true });
console.info(`%c LUMA-VACUUM-CARD %c v${LUMA_VACUUM_VERSION} `, 'background:#7EB8DA;color:#111;font-weight:bold', 'background:#161a22;color:#7EB8DA');
+94
View File
@@ -0,0 +1,94 @@
const LUMA_WEATHER_VERSION = '0.1.0';
class LumaWeatherCard 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 || '2x2',
icon: config.icon || '', 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: '2x2' }; }
connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._render(); }
_getState() { return this._hass?.states?.[this._config?.entity]; }
_getWeatherIcon(condition) {
const map = {
'sunny': 'mdi:weather-sunny', 'clear-night': 'mdi:weather-night',
'cloudy': 'mdi:weather-cloudy', 'fog': 'mdi:weather-fog',
'partlycloudy': 'mdi:weather-partly-cloudy',
'rainy': 'mdi:weather-rainy', 'pouring': 'mdi:weather-pouring',
'snowy': 'mdi:weather-snowy', 'lightning': 'mdi:weather-lightning',
'lightning-rainy': 'mdi:weather-lightning-rainy', 'hail': 'mdi:weather-hail',
'windy': 'mdi:weather-windy', 'windy-variant': 'mdi:weather-windy-variant',
'exceptional': 'mdi:alert-circle',
};
return map[condition] || 'mdi:weather-sunny';
}
_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 condition = s?.state || 'sunny';
const temp = s?.attributes?.temperature ?? '--';
const humidity = s?.attributes?.humidity ?? '--';
const wind = s?.attributes?.wind_speed ?? '--';
const icon = this._config.icon || this._getWeatherIcon(condition);
const conditionLabel = condition.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.card {
position: relative; overflow: hidden; background: rgba(36,40,48,0.75);
border: 1px solid rgba(255,255,255,0.10); border-radius: 22px; padding: 16px;
cursor: pointer; transition: border-color .15s, box-shadow .15s;
height: 100%; min-height: 200px; display: flex; flex-direction: column;
align-items: center; justify-content: center; text-align: center;
box-shadow: 0 2px 6px rgba(0,0,0,.06);
}
.card:hover { border-color: rgba(255,255,255,0.18); box-shadow: 0 8px 28px rgba(0,0,0,.28); }
.icon { position: relative; z-index: 1; }
.icon ha-icon { --mdc-icon-size: 64px; color: #f0d080; }
.temp { font-size: 40px; font-weight: 600; letter-spacing: -0.04em; color: #fff; margin-top: 12px; position: relative; z-index: 1; }
.condition { font-size: 15px; font-weight: 400; color: rgba(232,232,232,0.60); margin-top: 4px; position: relative; z-index: 1; }
.details { display: flex; gap: 16px; margin-top: 16px; position: relative; z-index: 1; }
.detail { text-align: center; }
.detail-label { font-size: 11px; font-weight: 400; letter-spacing: 0.04em; color: rgba(232,232,232,0.45); text-transform: uppercase; }
.detail-value { font-size: 14px; font-weight: 600; color: #fff; margin-top: 2px; }
</style>
<div class="card">
<div class="icon"><ha-icon icon="${icon}"></ha-icon></div>
<div class="temp">${temp}°</div>
<div class="condition">${conditionLabel}</div>
<div class="details">
<div class="detail">
<div class="detail-label">Humidity</div>
<div class="detail-value">${humidity}%</div>
</div>
<div class="detail">
<div class="detail-label">Wind</div>
<div class="detail-value">${wind} km/h</div>
</div>
</div>
</div>`;
this.shadowRoot.querySelector('.card').addEventListener('click', () => this._handleClick());
}
}
customElements.define('luma-weather-card', LumaWeatherCard);
window.customCards = window.customCards || [];
window.customCards.push({ type: 'luma-weather-card', name: 'Luma Weather', description: 'Luma-style weather hero card with temperature and conditions.', preview: true });
console.info(`%c LUMA-WEATHER-CARD %c v${LUMA_WEATHER_VERSION} `, 'background:#f0d080;color:#111;font-weight:bold', 'background:#161a22;color:#f0d080');