feat: HA light output targets — cast LED colors to Home Assistant lights
Some checks failed
Lint & Test / test (push) Has been cancelled
Some checks failed
Lint & Test / test (push) Has been cancelled
New output target type `ha_light` that sends averaged LED colors to HA light entities via WebSocket service calls (light.turn_on/turn_off): Backend: - HARuntime.call_service(): fire-and-forget WS service calls - HALightOutputTarget: data model with light mappings, update rate, transition - HALightTargetProcessor: processing loop with delta detection, rate limiting - ProcessorManager.add_ha_light_target(): registration - API schemas/routes updated for ha_light target type Frontend: - HA Light Targets section in Targets tab tree nav - Modal editor: HA source picker, CSS source picker, light entity mappings - Target cards with start/stop/clone/edit actions - i18n keys for all new UI strings
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* 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 } 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 = `<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 _editorCssSources: any[] = [];
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
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 _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 HTMLInputElement).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);
|
||||
}
|
||||
|
||||
// ── Mapping rows ──
|
||||
|
||||
export function addHALightMapping(data: any = null): void {
|
||||
const list = document.getElementById('ha-light-mappings-list');
|
||||
if (!list) return;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ha-light-mapping-row condition-fields';
|
||||
row.innerHTML = `
|
||||
<div class="condition-field">
|
||||
<label>${t('ha_light.mapping.entity_id')}</label>
|
||||
<input type="text" class="ha-mapping-entity" value="${escapeHtml(data?.entity_id || '')}" placeholder="light.living_room">
|
||||
</div>
|
||||
<div class="condition-field" style="display:flex; gap:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<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 style="flex:1">
|
||||
<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 style="flex:1">
|
||||
<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>
|
||||
<button type="button" class="btn btn-sm btn-danger ha-mapping-remove" onclick="this.closest('.ha-light-mapping-row').remove()">×</button>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
||||
// ── 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[] => []),
|
||||
]);
|
||||
_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
|
||||
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 || '';
|
||||
|
||||
// Load mappings
|
||||
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 = '';
|
||||
// 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'),
|
||||
});
|
||||
|
||||
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<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 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<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> = {}): 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: `
|
||||
<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">${ICON_HA} ${haName}</span>
|
||||
${cssName !== '—' ? `<span class="stream-card-prop">${_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>
|
||||
</div>
|
||||
${renderTagChips(target.tags || [])}
|
||||
${target.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(target.description)}</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>`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Expose to global scope ──
|
||||
|
||||
window.showHALightEditor = showHALightEditor;
|
||||
window.closeHALightEditor = closeHALightEditor;
|
||||
window.saveHALightEditor = saveHALightEditor;
|
||||
window.editHALightTarget = editHALightTarget;
|
||||
window.cloneHALightTarget = cloneHALightTarget;
|
||||
window.addHALightMapping = addHALightMapping;
|
||||
Reference in New Issue
Block a user