feat: HA light output targets — cast LED colors to Home Assistant lights
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:
2026-03-28 00:08:49 +03:00
parent fb98e6e2b8
commit cb9289f01f
25 changed files with 1679 additions and 164 deletions
@@ -11,6 +11,7 @@ import {
_cachedValueSources, valueSourcesCache,
streamsCache, audioSourcesCache, syncClocksCache,
colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
_cachedHASources, haSourcesCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts';
@@ -19,6 +20,7 @@ import { Modal } from '../core/modal.ts';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.ts';
import { _splitOpenrgbZone } from './device-discovery.ts';
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.ts';
import { createHALightTargetCard, initHALightTargetDelegation } from './ha-light-targets.ts';
import {
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
@@ -104,6 +106,7 @@ const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.de
] });
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, bulkActions: _targetBulkActions });
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, bulkActions: _targetBulkActions });
const csHALightTargets = new CardSection('ha-light-targets', { titleKey: 'ha_light.section.title', gridClass: 'devices-grid', addCardOnclick: "showHALightEditor()", keyAttr: 'data-ha-target-id', emptyKey: 'section.empty.ha_light_targets', bulkActions: _targetBulkActions });
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates', bulkActions: [
{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeletePatternTemplates },
] });
@@ -605,6 +608,7 @@ export async function loadTargetsTab() {
valueSourcesCache.fetch().catch((): any[] => []),
audioSourcesCache.fetch().catch((): any[] => []),
syncClocksCache.fetch().catch((): any[] => []),
haSourcesCache.fetch().catch((): any[] => []),
]);
const colorStripSourceMap = {};
@@ -658,6 +662,7 @@ export async function loadTargetsTab() {
const ledDevices = devicesWithState;
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
const haLightTargets = targetsWithState.filter(t => t.target_type === 'ha_light');
// Update tab badge with running target count
const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length;
@@ -680,10 +685,16 @@ export async function loadTargetsTab() {
{ key: 'kc-targets', titleKey: 'targets.section.key_colors', icon: getTargetTypeIcon('key_colors'), count: kcTargets.length },
{ key: 'kc-patterns', titleKey: 'targets.section.pattern_templates', icon: ICON_TEMPLATE, count: patternTemplates.length },
]
},
{
key: 'ha_light_group', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'ha_light.section.title',
children: [
{ key: 'ha-light-targets', titleKey: 'ha_light.section.targets', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: haLightTargets.length },
]
}
];
// Determine which tree leaf is active — migrate old values
const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns'];
const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns', 'ha-light-targets'];
const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab
: activeSubTab === 'key_colors' ? 'kc-targets' : 'led-devices';
@@ -694,6 +705,9 @@ export async function loadTargetsTab() {
const deviceItems = csDevices.applySortOrder(ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) })));
const ledTargetItems = csLedTargets.applySortOrder(ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) })));
const kcTargetItems = csKCTargets.applySortOrder(kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) })));
const haSourceMap: Record<string, any> = {};
_cachedHASources.forEach(s => { haSourceMap[s.id] = s; });
const haLightTargetItems = csHALightTargets.applySortOrder(haLightTargets.map(t => ({ key: t.id, html: createHALightTargetCard(t, haSourceMap, colorStripSourceMap) })));
const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
// Track which target cards were replaced/added (need chart re-init)
@@ -706,11 +720,13 @@ export async function loadTargetsTab() {
'led-targets': ledTargets.length,
'kc-targets': kcTargets.length,
'kc-patterns': patternTemplates.length,
'ha-light-targets': haLightTargets.length,
});
csDevices.reconcile(deviceItems);
const ledResult = csLedTargets.reconcile(ledTargetItems);
const kcResult = csKCTargets.reconcile(kcTargetItems);
csPatternTemplates.reconcile(patternItems);
csHALightTargets.reconcile(haLightTargetItems);
changedTargetIds = new Set<string>([...(ledResult.added as unknown as string[]), ...(ledResult.replaced as unknown as string[]), ...(ledResult.removed as unknown as string[]),
...(kcResult.added as unknown as string[]), ...(kcResult.replaced as unknown as string[]), ...(kcResult.removed as unknown as string[])]);
@@ -727,9 +743,11 @@ export async function loadTargetsTab() {
{ key: 'led-targets', html: csLedTargets.render(ledTargetItems) },
{ key: 'kc-targets', html: csKCTargets.render(kcTargetItems) },
{ key: 'kc-patterns', html: csPatternTemplates.render(patternItems) },
{ key: 'ha-light-targets', html: csHALightTargets.render(haLightTargetItems) },
].map(p => `<div class="target-sub-tab-panel stream-tab-panel${p.key === activeLeaf ? ' active' : ''}" id="target-sub-tab-${p.key}">${p.html}</div>`).join('');
container.innerHTML = panels;
CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates]);
CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates, csHALightTargets]);
initHALightTargetDelegation(container);
// Render tree sidebar with expand/collapse buttons
_targetsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);