Add HACS integration, 8 Lovelace cards, and pairing page
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:
1 parent
a890ba6baf
commit
d681f20884
18 files changed
+2708
No files matched your search
@@ -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');
|
||||||
@@ -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';
|
||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "Luma Home",
|
||||||
|
"render_readme": true,
|
||||||
|
"homeassistant": "2024.1.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Luma Home — config bridge between HA dashboards and Luma TV app."""
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from . import websocket_api
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||||
|
"""Set up the Luma Home component."""
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up Luma Home from a config entry."""
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = {
|
||||||
|
"dashboard_url_path": entry.data.get("dashboard_url_path", "luma-tv"),
|
||||||
|
}
|
||||||
|
websocket_api.async_register_commands(hass)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
hass.data[DOMAIN].pop(entry.entry_id, None)
|
||||||
|
return True
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Config flow for Luma Home."""
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.core import callback
|
||||||
|
|
||||||
|
from .const import DOMAIN, CONF_DASHBOARD_URL_PATH, DEFAULT_DASHBOARD_URL_PATH
|
||||||
|
|
||||||
|
|
||||||
|
class LumaHomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Luma Home."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input=None):
|
||||||
|
"""Handle the initial step."""
|
||||||
|
if self._async_current_entries():
|
||||||
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title="Luma Home",
|
||||||
|
data=user_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(
|
||||||
|
CONF_DASHBOARD_URL_PATH,
|
||||||
|
default=DEFAULT_DASHBOARD_URL_PATH,
|
||||||
|
): str,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@callback
|
||||||
|
def async_get_options_flow(config_entry):
|
||||||
|
return LumaHomeOptionsFlow(config_entry)
|
||||||
|
|
||||||
|
|
||||||
|
class LumaHomeOptionsFlow(config_entries.OptionsFlow):
|
||||||
|
"""Handle options."""
|
||||||
|
|
||||||
|
def __init__(self, config_entry):
|
||||||
|
self.config_entry = config_entry
|
||||||
|
|
||||||
|
async def async_step_init(self, user_input=None):
|
||||||
|
if user_input is not None:
|
||||||
|
return self.async_create_entry(title="", data=user_input)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="init",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(
|
||||||
|
CONF_DASHBOARD_URL_PATH,
|
||||||
|
default=self.config_entry.data.get(
|
||||||
|
CONF_DASHBOARD_URL_PATH, DEFAULT_DASHBOARD_URL_PATH
|
||||||
|
),
|
||||||
|
): str,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DOMAIN = "luma_home"
|
||||||
|
CONF_DASHBOARD_URL_PATH = "dashboard_url_path"
|
||||||
|
DEFAULT_DASHBOARD_URL_PATH = "luma-tv"
|
||||||
|
PAIRING_DIR = "luma-pairing"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"domain": "luma_home",
|
||||||
|
"name": "Luma Home",
|
||||||
|
"codeowners": ["@jazzymc"],
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://git.xtrm-lab.org/jazzymc/luma-home",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"issue_tracker": "https://git.xtrm-lab.org/jazzymc/luma-home/issues",
|
||||||
|
"requirements": [],
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Luma Home Setup",
|
||||||
|
"description": "Configure the dashboard that drives the Luma TV app.",
|
||||||
|
"data": {
|
||||||
|
"dashboard_url_path": "Dashboard URL path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"single_instance_allowed": "Luma Home is already configured."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"dashboard_url_path": "Dashboard URL path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""WebSocket API for Luma Home.
|
||||||
|
|
||||||
|
Exposes:
|
||||||
|
luma/get_config — returns v2 config derived from a Lovelace dashboard
|
||||||
|
luma/pair — writes a pairing file for TV token provisioning
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
from homeassistant.components import websocket_api
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
|
||||||
|
from .const import DOMAIN, DEFAULT_DASHBOARD_URL_PATH, PAIRING_DIR
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CONFIG_SCHEMA_VERSION = 2
|
||||||
|
|
||||||
|
LUMA_CARD_TYPE_MAP = {
|
||||||
|
"custom:luma-range-card": "range",
|
||||||
|
"custom:luma-toggle-card": "toggle",
|
||||||
|
"custom:luma-climate-card": "climate",
|
||||||
|
"custom:luma-media-card": "media",
|
||||||
|
"custom:luma-curtain-card": "curtain",
|
||||||
|
"custom:luma-action-card": "action",
|
||||||
|
"custom:luma-vacuum-card": "vacuum",
|
||||||
|
"custom:luma-weather-card": "weather",
|
||||||
|
}
|
||||||
|
|
||||||
|
HA_CARD_TYPE_MAP = {
|
||||||
|
"light": "range",
|
||||||
|
"thermostat": "climate",
|
||||||
|
"media-control": "media",
|
||||||
|
"weather-forecast": "weather",
|
||||||
|
"button": "action",
|
||||||
|
}
|
||||||
|
|
||||||
|
CONTAINER_CARD_TYPES = {
|
||||||
|
"vertical-stack",
|
||||||
|
"horizontal-stack",
|
||||||
|
"grid",
|
||||||
|
"conditional",
|
||||||
|
"entities",
|
||||||
|
"sections",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def async_register_commands(hass: HomeAssistant) -> None:
|
||||||
|
"""Register WS commands."""
|
||||||
|
websocket_api.async_register_command(hass, handle_get_config)
|
||||||
|
websocket_api.async_register_command(hass, handle_pair)
|
||||||
|
websocket_api.async_register_command(hass, handle_pair_status)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dashboard_path(hass: HomeAssistant) -> str:
|
||||||
|
"""Get the configured dashboard URL path."""
|
||||||
|
for entry_data in hass.data.get(DOMAIN, {}).values():
|
||||||
|
if isinstance(entry_data, dict):
|
||||||
|
return entry_data.get("dashboard_url_path", DEFAULT_DASHBOARD_URL_PATH)
|
||||||
|
return DEFAULT_DASHBOARD_URL_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_cards(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Recursively unwrap container cards to get leaf cards."""
|
||||||
|
result = []
|
||||||
|
for card in cards:
|
||||||
|
card_type = card.get("type", "")
|
||||||
|
if card_type in CONTAINER_CARD_TYPES:
|
||||||
|
children = card.get("cards", [])
|
||||||
|
if card_type == "conditional":
|
||||||
|
children = card.get("card", [])
|
||||||
|
if isinstance(children, dict):
|
||||||
|
children = [children]
|
||||||
|
elif card_type == "entities":
|
||||||
|
# entities card has entity rows, not sub-cards — skip it
|
||||||
|
# unless it wraps actual card children
|
||||||
|
children = card.get("cards", [])
|
||||||
|
result.extend(_flatten_cards(children))
|
||||||
|
else:
|
||||||
|
result.append(card)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _map_card(card: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""Map a Lovelace card config to a Luma v2 card config.
|
||||||
|
|
||||||
|
Returns None if the card type is not recognized.
|
||||||
|
"""
|
||||||
|
card_type = card.get("type", "")
|
||||||
|
|
||||||
|
# Custom Luma cards — direct passthrough
|
||||||
|
if card_type in LUMA_CARD_TYPE_MAP:
|
||||||
|
luma_type = LUMA_CARD_TYPE_MAP[card_type]
|
||||||
|
return {
|
||||||
|
"type": luma_type,
|
||||||
|
"entity_id": card.get("entity", card.get("entity_id", "")),
|
||||||
|
"name": card.get("name", ""),
|
||||||
|
"size": card.get("size", "1x1"),
|
||||||
|
"accent_color": card.get("accent_color", ""),
|
||||||
|
"glow_color": card.get("glow_color", ""),
|
||||||
|
"icon": card.get("icon", ""),
|
||||||
|
"tap_action": card.get("tap_action"),
|
||||||
|
"hold_action": card.get("hold_action"),
|
||||||
|
"confirmation": card.get("confirmation", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Standard HA cards — best-effort mapping
|
||||||
|
if card_type in HA_CARD_TYPE_MAP:
|
||||||
|
luma_type = HA_CARD_TYPE_MAP[card_type]
|
||||||
|
entity = card.get("entity", "")
|
||||||
|
return {
|
||||||
|
"type": luma_type,
|
||||||
|
"entity_id": entity,
|
||||||
|
"name": card.get("name", ""),
|
||||||
|
"size": "1x1",
|
||||||
|
"accent_color": "",
|
||||||
|
"glow_color": "",
|
||||||
|
"icon": "",
|
||||||
|
"tap_action": card.get("tap_action"),
|
||||||
|
"hold_action": card.get("hold_action"),
|
||||||
|
"confirmation": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Standard HA entity-domain based fallback for 'button' cards etc.
|
||||||
|
entity = card.get("entity", "")
|
||||||
|
if entity:
|
||||||
|
domain = entity.split(".")[0] if "." in entity else ""
|
||||||
|
domain_map = {
|
||||||
|
"light": "range",
|
||||||
|
"switch": "toggle",
|
||||||
|
"input_boolean": "toggle",
|
||||||
|
"climate": "climate",
|
||||||
|
"media_player": "media",
|
||||||
|
"cover": "curtain",
|
||||||
|
"scene": "action",
|
||||||
|
"script": "action",
|
||||||
|
"vacuum": "vacuum",
|
||||||
|
"weather": "weather",
|
||||||
|
}
|
||||||
|
if domain in domain_map:
|
||||||
|
return {
|
||||||
|
"type": domain_map[domain],
|
||||||
|
"entity_id": entity,
|
||||||
|
"name": card.get("name", ""),
|
||||||
|
"size": "1x1",
|
||||||
|
"accent_color": "",
|
||||||
|
"glow_color": "",
|
||||||
|
"icon": card.get("icon", ""),
|
||||||
|
"tap_action": card.get("tap_action"),
|
||||||
|
"hold_action": card.get("hold_action"),
|
||||||
|
"confirmation": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _map_view(view: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Map a Lovelace view to a Luma v2 room."""
|
||||||
|
raw_cards = view.get("cards", [])
|
||||||
|
flat_cards = _flatten_cards(raw_cards)
|
||||||
|
mapped = [c for c in (_map_card(card) for card in flat_cards) if c is not None]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": view.get("path", view.get("title", "").lower().replace(" ", "_")),
|
||||||
|
"name": view.get("title", ""),
|
||||||
|
"icon": view.get("icon", ""),
|
||||||
|
"theme": view.get("theme"),
|
||||||
|
"badges": view.get("badges", []),
|
||||||
|
"subview": view.get("subview", False),
|
||||||
|
"cards": mapped,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "luma/get_config",
|
||||||
|
vol.Optional("dashboard_url_path"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def handle_get_config(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Return the Luma v2 config derived from a Lovelace dashboard."""
|
||||||
|
url_path = msg.get("dashboard_url_path") or _get_dashboard_path(hass)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = await hass.data["lovelace"]["dashboards"][url_path].async_load(False)
|
||||||
|
except KeyError:
|
||||||
|
# Try the default dashboard
|
||||||
|
try:
|
||||||
|
config = await hass.data["lovelace"]["dashboards"][None].async_load(False)
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Dashboard '%s' not found, falling back to default", url_path
|
||||||
|
)
|
||||||
|
except (KeyError, Exception) as err:
|
||||||
|
connection.send_error(msg["id"], "not_found", f"Dashboard not found: {err}")
|
||||||
|
return
|
||||||
|
|
||||||
|
views = config.get("views", [])
|
||||||
|
rooms = [_map_view(v) for v in views]
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"version": CONFIG_SCHEMA_VERSION,
|
||||||
|
"rooms": rooms,
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.send_result(msg["id"], result)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "luma/pair",
|
||||||
|
vol.Required("pin"): str,
|
||||||
|
vol.Required("token"): str,
|
||||||
|
vol.Optional("ha_url"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def handle_pair(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Write a pairing file for TV token provisioning."""
|
||||||
|
pin = msg["pin"]
|
||||||
|
token = msg["token"]
|
||||||
|
ha_url = msg.get("ha_url", "")
|
||||||
|
|
||||||
|
if not pin.isdigit() or len(pin) != 6:
|
||||||
|
connection.send_error(msg["id"], "invalid_pin", "PIN must be 6 digits")
|
||||||
|
return
|
||||||
|
|
||||||
|
www_dir = Path(hass.config.path("www"))
|
||||||
|
pairing_dir = www_dir / PAIRING_DIR
|
||||||
|
pairing_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
pairing_file = pairing_dir / f"{pin}.json"
|
||||||
|
payload = {"token": token, "ha_url": ha_url, "pin": pin}
|
||||||
|
|
||||||
|
await hass.async_add_executor_job(
|
||||||
|
_write_pairing_file, pairing_file, payload
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.send_result(msg["id"], {"success": True})
|
||||||
|
_LOGGER.info("Pairing file written for PIN %s", pin)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_pairing_file(path: Path, data: dict) -> None:
|
||||||
|
"""Write pairing JSON to disk."""
|
||||||
|
path.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "luma/pair_status",
|
||||||
|
vol.Required("pin"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def handle_pair_status(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Check if a pairing file exists and return/clean it up.
|
||||||
|
|
||||||
|
Called by the TV after it polls the HTTP endpoint and finds the file.
|
||||||
|
This command lets authenticated clients clean up the pairing file.
|
||||||
|
"""
|
||||||
|
pin = msg["pin"]
|
||||||
|
www_dir = Path(hass.config.path("www"))
|
||||||
|
pairing_file = www_dir / PAIRING_DIR / f"{pin}.json"
|
||||||
|
|
||||||
|
if not await hass.async_add_executor_job(pairing_file.exists):
|
||||||
|
connection.send_result(msg["id"], {"paired": False})
|
||||||
|
return
|
||||||
|
|
||||||
|
data = json.loads(
|
||||||
|
await hass.async_add_executor_job(pairing_file.read_text, "utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up immediately
|
||||||
|
await hass.async_add_executor_job(pairing_file.unlink, True)
|
||||||
|
|
||||||
|
connection.send_result(
|
||||||
|
msg["id"],
|
||||||
|
{"paired": True, "token": data.get("token", ""), "ha_url": data.get("ha_url", "")},
|
||||||
|
)
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Luma TV Pairing</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0c0f14;
|
||||||
|
--surface: #161a22;
|
||||||
|
--card-border: rgba(255,255,255,0.08);
|
||||||
|
--text: #f0f0f0;
|
||||||
|
--text-muted: rgba(232,232,232,0.50);
|
||||||
|
--accent: #7EB8DA;
|
||||||
|
--danger: #e05555;
|
||||||
|
--radius: 16px;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 32px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 22px; font-weight: 600; margin-bottom: 4px; }
|
||||||
|
.subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 24px; }
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field label { display: block; font-size: 12px; font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.field input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.field input.pin-input {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
}
|
||||||
|
.field .hint { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--accent); color: #111; }
|
||||||
|
.btn-primary:hover { filter: brightness(1.1); }
|
||||||
|
.btn-primary:disabled { opacity: 0.5; cursor: default; filter: none; }
|
||||||
|
.msg { font-size: 14px; margin-top: 16px; text-align: center; min-height: 20px; }
|
||||||
|
.msg.error { color: var(--danger); }
|
||||||
|
.msg.success { color: #4ade80; }
|
||||||
|
.step-list { margin: 16px 0; padding-left: 20px; }
|
||||||
|
.step-list li { font-size: 13px; color: var(--text-muted); margin-bottom: 6px; line-height: 1.5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Luma TV Pairing</h1>
|
||||||
|
<p class="subtitle">Connect your TV to Home Assistant</p>
|
||||||
|
|
||||||
|
<ol class="step-list">
|
||||||
|
<li>On the TV, go to Settings → Home Assistant → Pair</li>
|
||||||
|
<li>Enter the 6-digit PIN shown on the TV screen below</li>
|
||||||
|
<li>Paste your HA long-lived access token</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>PIN from TV</label>
|
||||||
|
<input id="pin" type="text" class="pin-input" maxlength="6" inputmode="numeric" pattern="[0-9]*" placeholder="000000" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>HA Long-Lived Access Token</label>
|
||||||
|
<input id="token" type="password" placeholder="Paste token from HA Profile page">
|
||||||
|
<div class="hint">HA → Profile → Long-Lived Access Tokens → Create Token</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" id="pair-btn" onclick="doPair()">Pair TV</button>
|
||||||
|
<div class="msg" id="msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getHaBaseUrl() {
|
||||||
|
return window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doPair() {
|
||||||
|
const pin = document.getElementById('pin').value.trim();
|
||||||
|
const token = document.getElementById('token').value.trim();
|
||||||
|
const msg = document.getElementById('msg');
|
||||||
|
const btn = document.getElementById('pair-btn');
|
||||||
|
|
||||||
|
msg.className = 'msg';
|
||||||
|
msg.textContent = '';
|
||||||
|
|
||||||
|
if (!pin || pin.length !== 6 || !/^\d{6}$/.test(pin)) {
|
||||||
|
msg.className = 'msg error';
|
||||||
|
msg.textContent = 'Enter the 6-digit PIN from the TV.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
msg.className = 'msg error';
|
||||||
|
msg.textContent = 'Paste your HA access token.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
msg.textContent = 'Pairing...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const haUrl = getHaBaseUrl();
|
||||||
|
const wsUrl = haUrl.replace(/^http/, 'ws') + '/api/websocket';
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
ws.onopen = resolve;
|
||||||
|
ws.onerror = () => reject(new Error('WebSocket connection failed'));
|
||||||
|
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
const authMsg = await new Promise(resolve => {
|
||||||
|
ws.onmessage = e => resolve(JSON.parse(e.data));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authMsg.type === 'auth_required') {
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', access_token: token }));
|
||||||
|
const authResult = await new Promise(resolve => {
|
||||||
|
ws.onmessage = e => resolve(JSON.parse(e.data));
|
||||||
|
});
|
||||||
|
if (authResult.type !== 'auth_ok') {
|
||||||
|
throw new Error('Invalid token. Check your HA access token.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call luma/pair
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
id: 1,
|
||||||
|
type: 'luma/pair',
|
||||||
|
pin: pin,
|
||||||
|
token: token,
|
||||||
|
ha_url: wsUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
ws.onmessage = e => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.id === 1) resolve(data);
|
||||||
|
};
|
||||||
|
setTimeout(() => reject(new Error('Timeout waiting for response')), 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
msg.className = 'msg success';
|
||||||
|
msg.textContent = 'Paired! The TV will connect in a few seconds.';
|
||||||
|
btn.textContent = 'Done';
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error?.message || 'Pairing failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
msg.className = 'msg error';
|
||||||
|
msg.textContent = err.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pin').addEventListener('input', function() {
|
||||||
|
this.value = this.value.replace(/\D/g, '').slice(0, 6);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user