Files
ledgrab/server/src/wled_controller/static/js/features/ha-light-targets.ts
T
alexei.dolgolyov 40751fecb7
Lint & Test / test (push) Successful in 1m24s
feat: HA light target live color preview — per-entity swatches via WebSocket
- Cache per-entity colors in HALightTargetProcessor._update_lights()
- Broadcast colors_update to WS clients at target's update_rate
- WS endpoint: /api/v1/output-targets/{target_id}/ha-light/ws
- Frontend: connect WS when target runs, update swatch colors live
- Card shows colored boxes per mapped entity with entity name labels
2026-03-28 18:28:16 +03:00

647 lines
28 KiB
TypeScript

/**
* 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 = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── 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<void> {
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 <select> 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 = `<option value="">${t('ha_light.mapping.select_entity')}</option>` +
lightEntities.map((e: any) =>
`<option value="${e.entity_id}" ${e.entity_id === currentVal ? 'selected' : ''}>${escapeHtml(e.friendly_name || e.entity_id)}</option>`
).join('');
// Restore value if it was set but not in list
if (currentVal && !lightEntities.some((e: any) => e.entity_id === currentVal)) {
sel.innerHTML += `<option value="${escapeHtml(currentVal)}" selected>${escapeHtml(currentVal)}</option>`;
}
});
// 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 = `<option value="">${t('ha_light.mapping.select_entity')}</option>` +
lightEntities.map((e: any) =>
`<option value="${e.entity_id}" ${e.entity_id === selectedId ? 'selected' : ''}>${escapeHtml(e.friendly_name || e.entity_id)}</option>`
).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)
? `<option value="${escapeHtml(selectedId)}" selected>${escapeHtml(selectedId)}</option>`
: '';
row.innerHTML = `
<div class="ha-mapping-header">
<span class="ha-mapping-label">${_icon(P.lightbulb)} #${idx}</span>
<button type="button" class="btn-remove-mapping" onclick="removeHALightMapping(this)" title="${t('common.delete')}">${ICON_TRASH}</button>
</div>
<div class="ha-mapping-fields">
<div class="ha-mapping-field">
<label>${t('ha_light.mapping.entity_id')}</label>
<select class="ha-mapping-entity">${entityOptions}${extraOption}</select>
</div>
<div class="ha-mapping-range-row">
<div>
<label>${t('ha_light.mapping.led_start')}</label>
<input type="number" class="ha-mapping-led-start" value="${data?.led_start ?? 0}" min="0" step="1">
</div>
<div>
<label>${t('ha_light.mapping.led_end')}</label>
<input type="number" class="ha-mapping-led-end" value="${data?.led_end ?? -1}" min="-1" step="1">
</div>
<div>
<label>${t('ha_light.mapping.brightness')}</label>
<input type="number" class="ha-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
</div>
</div>
</div>
`;
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<void> {
// 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) =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
// Populate CSS source dropdown
const cssSelect = document.getElementById('ha-light-editor-css-source') as HTMLSelectElement;
cssSelect.innerHTML = `<option value="">—</option>` + cssSources.map((s: any) =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).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 = `<option value="">${t('targets.brightness_vs.none')}</option>` +
_cachedValueSources.map((vs: any) =>
`<option value="${vs.id}" ${vs.id === (editData?.brightness_value_source_id || '') ? 'selected' : ''}>${escapeHtml(vs.name)}</option>`
).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<void> {
await haLightEditorModal.close();
}
// ── Save ──
export async function saveHALightEditor(): Promise<void> {
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<void> {
await showHALightEditor(targetId);
}
export async function cloneHALightTarget(targetId: string): Promise<void> {
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<string, any> = {}, cssSourceMap: Record<string, any> = {}, valueSourceMap: Record<string, any> = {}): 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: `
<div class="card-header">
<span class="card-title-text">${ICON_HA} ${escapeHtml(target.name)}</span>
</div>
<div class="stream-card-props">
<span class="stream-card-prop${haLink}" title="HA Connection">${ICON_HA} ${haName}</span>
${cssName !== '—' ? `<span class="stream-card-prop${cssLink}" title="${t('targets.color_strip_source')}">${cssSource ? getColorStripIcon(cssSource.source_type) : _icon(P.palette)} ${cssName}</span>` : ''}
<span class="stream-card-prop">${_icon(P.listChecks)} ${mappingCount} light${mappingCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop">${_icon(P.clock)} ${target.update_rate ?? 2.0} Hz</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
</div>
${renderTagChips(target.tags || [])}
${target.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(target.description)}</div>` : ''}
<div class="card-content">
${isRunning ? `
<div class="metrics-grid target-metrics-expanded">
<div class="metric">
<div class="metric-label">${t('targets.fps')}</div>
<div class="metric-value" data-tm="fps">${(state.fps_actual ?? target.update_rate ?? 2).toFixed(1)} Hz</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.uptime')}</div>
<div class="metric-value" data-tm="uptime">${metrics.uptime_seconds ? formatUptime(metrics.uptime_seconds) : '---'}</div>
</div>
<div class="metric">
<div class="metric-label">HA</div>
<div class="metric-value" data-tm="ha-status">${state.ha_connected ? ICON_OK : ICON_WARNING}</div>
</div>
</div>
<div class="ha-light-swatches" data-ha-swatches="${target.id}">
${_renderEntitySwatches(state.entity_colors || {}, target.ha_light_mappings || [])}
</div>
` : ''}
</div>`,
actions: `
<button class="btn btn-icon ${isRunning ? 'btn-danger' : 'btn-primary'}" data-action="${isRunning ? 'stop' : 'start'}" title="${isRunning ? t('targets.stop') : t('targets.start')}">
${isRunning ? ICON_STOP : ICON_START}
</button>
<button class="btn btn-icon btn-secondary" data-action="clone" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── 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<string, (id: string) => void> = {
start: (id) => _startStop(id, 'start'),
stop: (id) => _startStop(id, 'stop'),
clone: cloneHALightTarget,
edit: editHALightTarget,
};
async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> {
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<HTMLElement>('[data-action]');
if (!btn) return;
const section = btn.closest<HTMLElement>('[data-card-section="ha-light-targets"]');
if (!section) return;
const card = btn.closest<HTMLElement>('[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<string, any>, 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 `<div class="ha-light-swatch" data-entity="${escapeHtml(m.entity_id)}">
<span class="swatch-color" style="background:${bg}"></span>
<span class="swatch-label">${escapeHtml(label)}</span>
</div>`;
}).join('');
}
// ── WebSocket color preview ──
const _haLightWS: Record<string, WebSocket> = {};
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<string, any>): 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;