/** * HA Light Targets — editor, cards, CRUD for Home Assistant light output targets. */ import { _cachedHASources, haSourcesCache, colorStripSourcesCache, outputTargetsCache } 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 } from '../core/ui.ts'; import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH } 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'; import { getColorStripIcon } from '../core/icons.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 _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; } _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 elements with new options const lightEntities = _cachedHAEntities.filter((e: any) => e.domain === 'light'); const rows = document.querySelectorAll('#ha-light-mappings-list .ha-light-mapping-row'); rows.forEach(row => { const sel = row.querySelector('.ha-mapping-entity') as HTMLSelectElement; if (!sel) return; const currentVal = sel.value; sel.innerHTML = `${t('ha_light.mapping.select_entity')}` + lightEntities.map((e: any) => `${escapeHtml(e.friendly_name || e.entity_id)}` ).join(''); // Restore value if it was set but not in list if (currentVal && !lightEntities.some((e: any) => e.entity_id === currentVal)) { sel.innerHTML += `${escapeHtml(currentVal)}`; } }); // Rebuild EntitySelect instances (destroy old, create new) _destroyMappingEntitySelects(); rows.forEach(row => { const sel = row.querySelector('.ha-mapping-entity') as HTMLSelectElement; if (!sel) return; const es = new EntitySelect({ target: sel, getItems: _getEntityItems, placeholder: t('ha_light.mapping.search_entity'), }); _mappingEntitySelects.push(es); }); } // ── Mapping rows ── export function addHALightMapping(data: any = null): void { const list = document.getElementById('ha-light-mappings-list'); if (!list) return; const idx = list.querySelectorAll('.ha-light-mapping-row').length + 1; const row = document.createElement('div'); row.className = 'ha-light-mapping-row'; // Build options from cached entities (light domain only) const lightEntities = _cachedHAEntities.filter((e: any) => e.domain === 'light'); const selectedId = data?.entity_id || ''; const entityOptions = `${t('ha_light.mapping.select_entity')}` + lightEntities.map((e: any) => `${escapeHtml(e.friendly_name || e.entity_id)}` ).join(''); // If editing and the entity isn't in the list (HA disconnected), add it manually const hasSelected = !selectedId || lightEntities.some((e: any) => e.entity_id === selectedId); const extraOption = (!hasSelected && selectedId) ? `${escapeHtml(selectedId)}` : ''; row.innerHTML = ` ${_icon(P.lightbulb)} #${idx} ${ICON_TRASH} ${t('ha_light.mapping.entity_id')} ${entityOptions}${extraOption} ${t('ha_light.mapping.led_start')} ${t('ha_light.mapping.led_end')} ${t('ha_light.mapping.brightness')} `; 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[] => []), ]); _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) => `${escapeHtml(s.name)}` ).join(''); // Populate CSS source dropdown const cssSelect = document.getElementById('ha-light-editor-css-source') as HTMLSelectElement; cssSelect.innerHTML = `—` + cssSources.map((s: any) => `${escapeHtml(s.name)}` ).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'), }); // 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 transition = parseFloat((document.getElementById('ha-light-editor-transition') as HTMLInputElement).value) || 0.5; 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 payload: any = { name, ha_source_id: haSourceId, color_strip_source_id: cssSourceId, 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 = {}): 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 cssName = cssSource ? escapeHtml(cssSource.name) : target.color_strip_source_id || '—'; const mappingCount = target.ha_light_mappings?.length || 0; const isRunning = target.state?.processing; 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 !== '—' ? `${_icon(P.palette)} ${cssName}` : ''} ${_icon(P.listChecks)} ${mappingCount} light${mappingCount !== 1 ? 's' : ''} ${_icon(P.clock)} ${target.update_rate ?? 2.0} Hz ${renderTagChips(target.tags || [])} ${target.description ? `${escapeHtml(target.description)}` : ''}`, actions: ` ${isRunning ? ICON_STOP : ICON_START} ${ICON_CLONE} ${ICON_EDIT}`, }); } // ── 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); } }); } // ── 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;