/** * HA Light Targets — editor, cards, CRUD for Home Assistant light output targets. */ import { _cachedHASources, _cachedValueSources, haSourcesCache, colorStripSourcesCache, outputTargetsCache, valueSourcesCache } from '../core/state.ts'; import { fetchWithAuth, escapeHtml } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm, formatUptime } from '../core/ui.ts'; import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, getColorStripIcon, getValueSourceIcon } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { EntitySelect } from '../core/entity-palette.ts'; import { wrapCard } from '../core/card-colors.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; const ICON_HA = `${P.home}`; const _icon = (d: string) => `${d}`; // ── Modal ── let _haLightTagsInput: TagInput | null = null; let _haSourceEntitySelect: EntitySelect | null = null; let _cssSourceEntitySelect: EntitySelect | null = null; let _brightnessVsEntitySelect: EntitySelect | null = null; let _mappingEntitySelects: EntitySelect[] = []; let _editorCssSources: any[] = []; let _cachedHAEntities: any[] = []; // fetched from selected HA source class HALightEditorModal extends Modal { constructor() { super('ha-light-editor-modal'); } onForceClose() { if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; } if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; } if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; } if (_brightnessVsEntitySelect) { _brightnessVsEntitySelect.destroy(); _brightnessVsEntitySelect = null; } _destroyMappingEntitySelects(); } snapshotValues() { return { name: (document.getElementById('ha-light-editor-name') as HTMLInputElement).value, ha_source: (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value, css_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value, update_rate: (document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value, transition: (document.getElementById('ha-light-editor-transition') as HTMLInputElement).value, mappings: _getMappingsJSON(), tags: JSON.stringify(_haLightTagsInput ? _haLightTagsInput.getValue() : []), }; } } const haLightEditorModal = new HALightEditorModal(); function _destroyMappingEntitySelects(): void { for (const es of _mappingEntitySelects) es.destroy(); _mappingEntitySelects = []; } function _getMappingsJSON(): string { const rows = document.querySelectorAll('#ha-light-mappings-list .ha-light-mapping-row'); const mappings: any[] = []; rows.forEach(row => { mappings.push({ entity_id: (row.querySelector('.ha-mapping-entity') as HTMLSelectElement).value.trim(), led_start: parseInt((row.querySelector('.ha-mapping-led-start') as HTMLInputElement).value) || 0, led_end: parseInt((row.querySelector('.ha-mapping-led-end') as HTMLInputElement).value) || -1, brightness_scale: parseFloat((row.querySelector('.ha-mapping-brightness') as HTMLInputElement).value) || 1.0, }); }); return JSON.stringify(mappings); } function _getEntityItems() { return _cachedHAEntities .filter((e: any) => e.domain === 'light') .map((e: any) => ({ value: e.entity_id, label: e.friendly_name || e.entity_id, icon: _icon(P.lightbulb), desc: e.state || '', })); } async function _fetchHAEntities(haSourceId: string): Promise { if (!haSourceId) { _cachedHAEntities = []; return; } try { const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`); if (!resp.ok) { _cachedHAEntities = []; return; } const data = await resp.json(); _cachedHAEntities = data.entities || []; } catch { _cachedHAEntities = []; } } function _rebuildMappingEntityOptions(): void { // Update all existing mapping entity ${entityOptions}${extraOption}
`; list.appendChild(row); // Attach EntitySelect to the entity dropdown const entitySelect = row.querySelector('.ha-mapping-entity') as HTMLSelectElement; const es = new EntitySelect({ target: entitySelect, getItems: _getEntityItems, placeholder: t('ha_light.mapping.search_entity'), }); _mappingEntitySelects.push(es); } export function removeHALightMapping(btn: HTMLElement): void { const row = btn.closest('.ha-light-mapping-row'); if (!row) return; // Find and destroy the EntitySelect for this row const select = row.querySelector('.ha-mapping-entity') as HTMLSelectElement; if (select) { const idx = _mappingEntitySelects.findIndex(es => (es as any)._target === select || (es as any).target === select); if (idx >= 0) { _mappingEntitySelects[idx].destroy(); _mappingEntitySelects.splice(idx, 1); } } row.remove(); } // ── Show / Close ── export async function showHALightEditor(targetId: string | null = null, cloneData: any = null): Promise { // Load data for dropdowns const [haSources, cssSources] = await Promise.all([ haSourcesCache.fetch().catch((): any[] => []), colorStripSourcesCache.fetch().catch((): any[] => []), valueSourcesCache.fetch().catch(() => {}), ]); _editorCssSources = cssSources; const isEdit = !!targetId; const isClone = !!cloneData; const titleKey = isEdit ? 'ha_light.edit' : 'ha_light.add'; document.getElementById('ha-light-editor-title')!.innerHTML = `${ICON_HA} ${t(titleKey)}`; (document.getElementById('ha-light-editor-id') as HTMLInputElement).value = ''; (document.getElementById('ha-light-editor-error') as HTMLElement).style.display = 'none'; // Populate HA source dropdown const haSelect = document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement; haSelect.innerHTML = haSources.map((s: any) => `` ).join(''); // Populate CSS source dropdown const cssSelect = document.getElementById('ha-light-editor-css-source') as HTMLSelectElement; cssSelect.innerHTML = `` + cssSources.map((s: any) => `` ).join(''); // Clear mappings _destroyMappingEntitySelects(); document.getElementById('ha-light-mappings-list')!.innerHTML = ''; let editData: any = null; if (isEdit) { try { const resp = await fetchWithAuth(`/output-targets/${targetId}`); if (!resp.ok) throw new Error('Failed to load target'); editData = await resp.json(); } catch (e: any) { if (e.isAuth) return; showToast(e.message, 'error'); return; } } else if (isClone) { editData = cloneData; } if (editData) { if (isEdit) (document.getElementById('ha-light-editor-id') as HTMLInputElement).value = editData.id; (document.getElementById('ha-light-editor-name') as HTMLInputElement).value = editData.name || ''; haSelect.value = editData.ha_source_id || ''; cssSelect.value = editData.color_strip_source_id || ''; (document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value = String(editData.update_rate ?? 2.0); document.getElementById('ha-light-editor-update-rate-display')!.textContent = (editData.update_rate ?? 2.0).toFixed(1); (document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = String(editData.ha_transition ?? 0.5); document.getElementById('ha-light-editor-transition-display')!.textContent = (editData.ha_transition ?? 0.5).toFixed(1); (document.getElementById('ha-light-editor-description') as HTMLInputElement).value = editData.description || ''; // Fetch entities from the selected HA source before loading mappings await _fetchHAEntities(editData.ha_source_id || ''); // Load mappings (with entity picker populated) const mappings = editData.ha_light_mappings || []; mappings.forEach((m: any) => addHALightMapping(m)); } else { (document.getElementById('ha-light-editor-name') as HTMLInputElement).value = ''; (document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value = '2.0'; document.getElementById('ha-light-editor-update-rate-display')!.textContent = '2.0'; (document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = '0.5'; document.getElementById('ha-light-editor-transition-display')!.textContent = '0.5'; (document.getElementById('ha-light-editor-description') as HTMLInputElement).value = ''; // Fetch entities from the first HA source const firstSource = haSources[0]?.id || ''; await _fetchHAEntities(firstSource); // Add one empty mapping by default addHALightMapping(); } // EntitySelects if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; } _haSourceEntitySelect = new EntitySelect({ target: haSelect, getItems: () => haSources.map((s: any) => ({ value: s.id, label: s.name, icon: ICON_HA, desc: s.connected ? t('ha_source.connected') : t('ha_source.disconnected'), })), placeholder: t('palette.search'), onChange: async (newSourceId: string) => { // Refetch entities when HA source changes, then rebuild mapping selects await _fetchHAEntities(newSourceId); _rebuildMappingEntityOptions(); }, }); if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; } _cssSourceEntitySelect = new EntitySelect({ target: cssSelect, getItems: () => _editorCssSources.map((s: any) => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type), desc: s.source_type, })), placeholder: t('palette.search'), }); // Brightness value source const bvsSelect = document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement; bvsSelect.innerHTML = `` + _cachedValueSources.map((vs: any) => `` ).join(''); if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy(); _brightnessVsEntitySelect = new EntitySelect({ target: bvsSelect, getItems: () => _cachedValueSources.map((vs: any) => ({ value: vs.id, label: vs.name, icon: getValueSourceIcon(vs.source_type), desc: vs.source_type, })), placeholder: t('palette.search'), allowNone: true, noneLabel: t('targets.brightness_vs.none'), }); // Tags if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; } _haLightTagsInput = new TagInput(document.getElementById('ha-light-tags-container'), { placeholder: t('tags.placeholder') }); _haLightTagsInput.setValue(editData?.tags || []); haLightEditorModal.open(); haLightEditorModal.snapshot(); } export async function closeHALightEditor(): Promise { await haLightEditorModal.close(); } // ── Save ── export async function saveHALightEditor(): Promise { const targetId = (document.getElementById('ha-light-editor-id') as HTMLInputElement).value; const name = (document.getElementById('ha-light-editor-name') as HTMLInputElement).value.trim(); const haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value; const cssSourceId = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value; const updateRate = parseFloat((document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value) || 2.0; const transitionRaw = parseFloat((document.getElementById('ha-light-editor-transition') as HTMLInputElement).value); const transition = isNaN(transitionRaw) ? 0.5 : transitionRaw; const description = (document.getElementById('ha-light-editor-description') as HTMLInputElement).value.trim() || null; if (!name) { haLightEditorModal.showError(t('ha_light.error.name_required')); return; } if (!haSourceId) { haLightEditorModal.showError(t('ha_light.error.ha_source_required')); return; } // Collect mappings const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.entity_id); const brightnessVsId = (document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement).value; const payload: any = { name, ha_source_id: haSourceId, color_strip_source_id: cssSourceId, brightness_value_source_id: brightnessVsId, ha_light_mappings: mappings, update_rate: updateRate, transition, description, tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [], }; try { let response; if (targetId) { response = await fetchWithAuth(`/output-targets/${targetId}`, { method: 'PUT', body: JSON.stringify(payload), }); } else { payload.target_type = 'ha_light'; response = await fetchWithAuth('/output-targets', { method: 'POST', body: JSON.stringify(payload), }); } if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${response.status}`); } showToast(targetId ? t('ha_light.updated') : t('ha_light.created'), 'success'); outputTargetsCache.invalidate(); haLightEditorModal.forceClose(); // Reload targets tab if (window.loadTargetsTab) await window.loadTargetsTab(); } catch (e: any) { if (e.isAuth) return; haLightEditorModal.showError(e.message); } } // ── Edit / Clone / Delete ── export async function editHALightTarget(targetId: string): Promise { await showHALightEditor(targetId); } export async function cloneHALightTarget(targetId: string): Promise { try { const resp = await fetchWithAuth(`/output-targets/${targetId}`); if (!resp.ok) throw new Error('Failed to load target'); const data = await resp.json(); delete data.id; data.name = data.name + ' (copy)'; await showHALightEditor(null, data); } catch (e: any) { if (e.isAuth) return; showToast(e.message, 'error'); } } // ── Card rendering ── export function createHALightTargetCard(target: any, haSourceMap: Record = {}, cssSourceMap: Record = {}, valueSourceMap: Record = {}): string { const haSource = haSourceMap[target.ha_source_id]; const cssSource = cssSourceMap[target.color_strip_source_id]; const haName = haSource ? escapeHtml(haSource.name) : target.ha_source_id || '—'; const cssId = target.color_strip_source_id; const cssName = cssSource ? escapeHtml(cssSource.name) : cssId || '—'; const mappingCount = target.ha_light_mappings?.length || 0; const isRunning = target.state?.processing; const state = target.state || {}; const metrics = target.metrics || {}; // Crosslinks const haLink = haSource ? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','home_assistant','ha-sources','data-id','${target.ha_source_id}')` : ''; const cssLink = cssSource ? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')` : ''; // Brightness value source const bvsId = target.brightness_value_source_id || ''; const bvs = bvsId && valueSourceMap[bvsId] ? valueSourceMap[bvsId] : null; return wrapCard({ type: 'card', dataAttr: 'data-ha-target-id', id: target.id, removeOnclick: `deleteTarget('${target.id}')`, removeTitle: t('common.delete'), content: `
${ICON_HA} ${escapeHtml(target.name)}
${ICON_HA} ${haName} ${cssName !== '—' ? `${cssSource ? getColorStripIcon(cssSource.source_type) : _icon(P.palette)} ${cssName}` : ''} ${_icon(P.listChecks)} ${mappingCount} light${mappingCount !== 1 ? 's' : ''} ${_icon(P.clock)} ${target.update_rate ?? 2.0} Hz ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''}
${renderTagChips(target.tags || [])} ${target.description ? `
${escapeHtml(target.description)}
` : ''}
${isRunning ? `
${t('targets.fps')}
${(state.fps_actual ?? target.update_rate ?? 2).toFixed(1)} Hz
${t('device.metrics.uptime')}
${metrics.uptime_seconds ? formatUptime(metrics.uptime_seconds) : '---'}
HA
${state.ha_connected ? ICON_OK : ICON_WARNING}
${_renderEntitySwatches(state.entity_colors || {}, target.ha_light_mappings || [])}
` : ''}
`, actions: ` `, }); } // ── Metrics patching ── export function patchHALightTargetMetrics(target: any): void { const card = document.querySelector(`[data-ha-target-id="${target.id}"]`); if (!card) return; const state = target.state || {}; const metrics = target.metrics || {}; const fpsEl = card.querySelector('[data-tm="fps"]') as HTMLElement | null; if (fpsEl) fpsEl.textContent = `${(state.fps_actual ?? 0).toFixed(1)} Hz`; const uptimeEl = card.querySelector('[data-tm="uptime"]') as HTMLElement | null; if (uptimeEl) uptimeEl.textContent = metrics.uptime_seconds ? formatUptime(metrics.uptime_seconds) : '---'; const haEl = card.querySelector('[data-tm="ha-status"]') as HTMLElement | null; if (haEl) haEl.innerHTML = state.ha_connected ? ICON_OK : ICON_WARNING; } // ── Event delegation ── const _haLightActions: Record void> = { start: (id) => _startStop(id, 'start'), stop: (id) => _startStop(id, 'stop'), clone: cloneHALightTarget, edit: editHALightTarget, }; async function _startStop(targetId: string, action: 'start' | 'stop'): Promise { try { const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); outputTargetsCache.invalidate(); if (window.loadTargetsTab) await window.loadTargetsTab(); } catch (e: any) { if (e.isAuth) return; showToast(e.message, 'error'); } } export function initHALightTargetDelegation(container: HTMLElement): void { container.addEventListener('click', (e: MouseEvent) => { const btn = (e.target as HTMLElement).closest('[data-action]'); if (!btn) return; const section = btn.closest('[data-card-section="ha-light-targets"]'); if (!section) return; const card = btn.closest('[data-ha-target-id]'); if (!card) return; const action = btn.dataset.action; const id = card.getAttribute('data-ha-target-id'); if (!action || !id) return; const handler = _haLightActions[action]; if (handler) { e.stopPropagation(); handler(id); } }); } // ── Entity color swatches ── function _renderEntitySwatches(entityColors: Record, mappings: any[]): string { if (!mappings.length) return ''; return mappings.map(m => { const c = entityColors[m.entity_id]; const bg = c ? c.hex : '#333'; const label = m.entity_id.replace('light.', ''); return `
${escapeHtml(label)}
`; }).join(''); } // ── WebSocket color preview ── const _haLightWS: Record = {}; export function connectHALightWS(targetId: string): void { if (_haLightWS[targetId]) return; const loc = window.location; const wsProto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; const apiKey = (window as any).apiKey || localStorage.getItem('wled_api_key') || ''; const url = `${wsProto}//${loc.host}/api/v1/output-targets/${targetId}/ha-light/ws?token=${encodeURIComponent(apiKey)}`; const ws = new WebSocket(url); _haLightWS[targetId] = ws; ws.onmessage = (ev) => { try { const data = JSON.parse(ev.data); if (data.type === 'colors_update') { _updateSwatchColors(targetId, data.colors); } } catch {} }; ws.onclose = () => { delete _haLightWS[targetId]; }; ws.onerror = () => { delete _haLightWS[targetId]; }; } export function disconnectHALightWS(targetId: string): void { const ws = _haLightWS[targetId]; if (ws) { ws.close(); delete _haLightWS[targetId]; } } export function disconnectAllHALightWS(): void { for (const id of Object.keys(_haLightWS)) { disconnectHALightWS(id); } } function _updateSwatchColors(targetId: string, colors: Record): void { const container = document.querySelector(`[data-ha-swatches="${targetId}"]`); if (!container) return; for (const [entityId, c] of Object.entries(colors)) { const swatch = container.querySelector(`[data-entity="${entityId}"] .swatch-color`) as HTMLElement | null; if (swatch) { swatch.style.background = (c as any).hex; } } } // ── Expose to global scope ── window.showHALightEditor = showHALightEditor; window.closeHALightEditor = closeHALightEditor; window.saveHALightEditor = saveHALightEditor; window.editHALightTarget = editHALightTarget; window.cloneHALightTarget = cloneHALightTarget; window.addHALightMapping = addHALightMapping; window.removeHALightMapping = removeHALightMapping;