diff --git a/cards/luma-action-card.js b/cards/luma-action-card.js new file mode 100644 index 0000000..497b7f4 --- /dev/null +++ b/cards/luma-action-card.js @@ -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 = ` + +
+
+
+
+
${typeLabel}
+
${name}
+
+
+
Tap to activate
+
`; + 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'); diff --git a/cards/luma-cards.js b/cards/luma-cards.js new file mode 100644 index 0000000..853c2c8 --- /dev/null +++ b/cards/luma-cards.js @@ -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'; diff --git a/cards/luma-climate-card.js b/cards/luma-climate-card.js new file mode 100644 index 0000000..72e80e0 --- /dev/null +++ b/cards/luma-climate-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 = ` + +
+
+
+
+
+
${modeLabel}
+
${name}
+
+
+
+
Now ${currentTemp}°C
+
${targetTemp}°
+
+
`; + 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'); diff --git a/cards/luma-curtain-card.js b/cards/luma-curtain-card.js new file mode 100644 index 0000000..a2df960 --- /dev/null +++ b/cards/luma-curtain-card.js @@ -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 = ` + +
+
+
+
+
+
+
Curtain
+
${name}
+
+
+
${label}
+
`; + 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'); diff --git a/cards/luma-media-card.js b/cards/luma-media-card.js new file mode 100644 index 0000000..cf593ed --- /dev/null +++ b/cards/luma-media-card.js @@ -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 = ` + +
+
+
+
+
${title}
+ ${artist ? `
${artist}
` : ''} +
${statusLabel}
+
+
${volLabel}
+
`; + 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'); diff --git a/cards/luma-range-card.js b/cards/luma-range-card.js new file mode 100644 index 0000000..c1a8071 --- /dev/null +++ b/cards/luma-range-card.js @@ -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 = ` + +
+
+
+
+
+
Light
+
${name}
+
+
+
${label}
+
`; + 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'); diff --git a/cards/luma-toggle-card.js b/cards/luma-toggle-card.js new file mode 100644 index 0000000..dde4d61 --- /dev/null +++ b/cards/luma-toggle-card.js @@ -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 = ` + +
+
+
+
+ +
+
+
${typeLabel}
+
${name}
+
+
+
${status}
+
+ `; + + 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'); diff --git a/cards/luma-vacuum-card.js b/cards/luma-vacuum-card.js new file mode 100644 index 0000000..b7230cb --- /dev/null +++ b/cards/luma-vacuum-card.js @@ -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 = ` + +
+
+
+
+
+
Vacuum
+
${name}
+
+
+
${displayStatus}
+
`; + 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'); diff --git a/cards/luma-weather-card.js b/cards/luma-weather-card.js new file mode 100644 index 0000000..2245361 --- /dev/null +++ b/cards/luma-weather-card.js @@ -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 = ` + +
+
+
${temp}°
+
${conditionLabel}
+
+
+
Humidity
+
${humidity}%
+
+
+
Wind
+
${wind} km/h
+
+
+
`; + 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'); diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..d0eb757 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "Luma Home", + "render_readme": true, + "homeassistant": "2024.1.0" +} diff --git a/integration/custom_components/luma_home/__init__.py b/integration/custom_components/luma_home/__init__.py new file mode 100644 index 0000000..c03e921 --- /dev/null +++ b/integration/custom_components/luma_home/__init__.py @@ -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 diff --git a/integration/custom_components/luma_home/config_flow.py b/integration/custom_components/luma_home/config_flow.py new file mode 100644 index 0000000..50d4b82 --- /dev/null +++ b/integration/custom_components/luma_home/config_flow.py @@ -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, + } + ), + ) diff --git a/integration/custom_components/luma_home/const.py b/integration/custom_components/luma_home/const.py new file mode 100644 index 0000000..786dd29 --- /dev/null +++ b/integration/custom_components/luma_home/const.py @@ -0,0 +1,4 @@ +DOMAIN = "luma_home" +CONF_DASHBOARD_URL_PATH = "dashboard_url_path" +DEFAULT_DASHBOARD_URL_PATH = "luma-tv" +PAIRING_DIR = "luma-pairing" diff --git a/integration/custom_components/luma_home/manifest.json b/integration/custom_components/luma_home/manifest.json new file mode 100644 index 0000000..8197db2 --- /dev/null +++ b/integration/custom_components/luma_home/manifest.json @@ -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" +} diff --git a/integration/custom_components/luma_home/strings.json b/integration/custom_components/luma_home/strings.json new file mode 100644 index 0000000..bde2ef5 --- /dev/null +++ b/integration/custom_components/luma_home/strings.json @@ -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" + } + } + } + } +} diff --git a/integration/custom_components/luma_home/websocket_api.py b/integration/custom_components/luma_home/websocket_api.py new file mode 100644 index 0000000..850a42b --- /dev/null +++ b/integration/custom_components/luma_home/websocket_api.py @@ -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", "")}, + ) diff --git a/pairing/luma-pair.html b/pairing/luma-pair.html new file mode 100644 index 0000000..87d5c4f --- /dev/null +++ b/pairing/luma-pair.html @@ -0,0 +1,198 @@ + + + + + +Luma TV Pairing + + + +
+

Luma TV Pairing

+

Connect your TV to Home Assistant

+ +
    +
  1. On the TV, go to Settings → Home Assistant → Pair
  2. +
  3. Enter the 6-digit PIN shown on the TV screen below
  4. +
  5. Paste your HA long-lived access token
  6. +
+ +
+ + +
+ +
+ + +
HA → Profile → Long-Lived Access Tokens → Create Token
+
+ + +
+
+ + + + diff --git a/web-dashboard/index.html b/web-dashboard/index.html new file mode 100644 index 0000000..6f2b46b --- /dev/null +++ b/web-dashboard/index.html @@ -0,0 +1,1066 @@ + + + + + +Luma Home + + + + + + +
+
+

Luma Home

+
+ + +
+
+ + +
+
+ +
+
+ + + + +
+
+

—

+
+ + + +
+
+
+
+
+
+ + +
+
+

Edit Card

+ +
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+ +
+
+
+
+ + + +