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>
270 lines
7.9 KiB
JavaScript
270 lines
7.9 KiB
JavaScript
/**
|
|
* 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');
|