refactor: key colors targets → CSS source type, HA target improvements
Lint & Test / test (push) Successful in 1m26s

Key Colors refactor:
- New `key_colors` CSS source type with inline rectangles
- KeyColorsColorStripStream: extracts N colors from screen regions
- CSS editor: EntitySelect for picture source, IconSelect for color mode
- Configure Regions button on card opens pattern canvas editor
- Live WS preview at 5 FPS with rectangle overlay + color swatches
- Removed KC target type, pattern template entity, and related API routes
- Removed KC/pattern template sections from Targets tab

HA light target improvements:
- Update rate, transition, mappings, brightness VS now editable via PUT
- Card crosslinks for HA source, CSS source, brightness VS
- HA connection status icon, text metrics (Hz, uptime)
- Brightness value source selector in editor
This commit is contained in:
2026-03-28 15:28:22 +03:00
parent 89d1b13854
commit 3e6760f726
46 changed files with 2707 additions and 789 deletions
@@ -6,11 +6,10 @@ import {
apiKey,
_targetEditorDevices, set_targetEditorDevices,
_deviceBrightnessCache,
kcWebSockets,
ledPreviewWebSockets,
_cachedValueSources, valueSourcesCache,
streamsCache, audioSourcesCache, syncClocksCache,
colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
colorStripSourcesCache, devicesCache, outputTargetsCache,
_cachedHASources, haSourcesCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice, fetchMetricsHistory } from '../core/api.ts';
@@ -19,8 +18,7 @@ import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing,
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 { createHALightTargetCard, initHALightTargetDelegation, patchHALightTargetMetrics } from './ha-light-targets.ts';
import {
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
@@ -83,16 +81,6 @@ async function _bulkDeleteDevices(ids: any) {
await loadTargetsTab();
}
async function _bulkDeletePatternTemplates(ids: any) {
const results = await Promise.allSettled(ids.map(id =>
fetchWithAuth(`/pattern-templates/${id}`, { method: 'DELETE' })
));
const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t('targets.deleted'), 'success');
patternTemplatesCache.invalidate();
await loadTargetsTab();
}
const _targetBulkActions = [
{ key: 'start', labelKey: 'bulk.start', icon: ICON_START, handler: _bulkStartTargets },
@@ -105,11 +93,7 @@ const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.de
{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteDevices },
] });
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 },
] });
// Re-render targets tab when language changes (only if tab is active)
document.addEventListener('languageChanged', () => {
@@ -582,8 +566,6 @@ export function switchTargetSubTab(tabKey: any) {
const _targetSectionMap = {
'led-devices': [csDevices],
'led-targets': [csLedTargets],
'kc-targets': [csKCTargets],
'kc-patterns': [csPatternTemplates],
};
let _loadTargetsLock = false;
@@ -599,11 +581,10 @@ export async function loadTargetsTab() {
try {
// Fetch all entities via DataCache
const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([
const [devices, targets, cssArr, psArr, valueSrcArr, asSrcArr] = await Promise.all([
devicesCache.fetch().catch((): any[] => []),
outputTargetsCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
patternTemplatesCache.fetch().catch((): any[] => []),
streamsCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch().catch((): any[] => []),
audioSourcesCache.fetch().catch((): any[] => []),
@@ -617,9 +598,6 @@ export async function loadTargetsTab() {
let pictureSourceMap = {};
psArr.forEach(s => { pictureSourceMap[s.id] = s; });
let patternTemplateMap = {};
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
let valueSourceMap = {};
valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; });
@@ -661,7 +639,6 @@ export async function loadTargetsTab() {
// Group by type
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
@@ -679,13 +656,6 @@ export async function loadTargetsTab() {
{ key: 'led-targets', titleKey: 'targets.section.targets', icon: getTargetTypeIcon('led'), count: ledTargets.length },
]
},
{
key: 'kc_group', icon: getTargetTypeIcon('key_colors'), titleKey: 'targets.subtab.key_colors',
children: [
{ 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: [
@@ -694,21 +664,15 @@ export async function loadTargetsTab() {
}
];
// Determine which tree leaf is active — migrate old values
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';
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
const validLeaves = ['led-devices', 'led-targets', 'ha-light-targets'];
const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab : 'led-devices';
// Build items arrays for each section (apply saved drag order)
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) })));
const haLightTargetItems = csHALightTargets.applySortOrder(haLightTargets.map(t => ({ key: t.id, html: createHALightTargetCard(t, haSourceMap, colorStripSourceMap, valueSourceMap) })));
// Track which target cards were replaced/added (need chart re-init)
let changedTargetIds: Set<string> | null = null;
@@ -718,17 +682,12 @@ export async function loadTargetsTab() {
_targetsTree.updateCounts({
'led-devices': ledDevices.length,
'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[])]);
changedTargetIds = new Set<string>([...(ledResult.added as unknown as string[]), ...(ledResult.replaced as unknown as string[]), ...(ledResult.removed as unknown as string[])]);
// Restore LED preview state on replaced cards (panel hidden by default in HTML)
for (const id of Array.from(ledResult.replaced) as any[]) {
@@ -741,12 +700,10 @@ export async function loadTargetsTab() {
const panels = [
{ key: 'led-devices', html: csDevices.render(deviceItems) },
{ 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, csHALightTargets]);
CardSection.bindAll([csDevices, csLedTargets, csHALightTargets]);
initHALightTargetDelegation(container);
// Render tree sidebar with expand/collapse buttons
@@ -757,18 +714,15 @@ export async function loadTargetsTab() {
// Show/hide stop-all buttons based on running state
const ledRunning = ledTargets.some(t => t.state && t.state.processing);
const kcRunning = kcTargets.some(t => t.state && t.state.processing);
const ledStopBtn = container.querySelector('[data-stop-all="led"]') as HTMLElement | null;
const kcStopBtn = container.querySelector('[data-stop-all="kc"]') as HTMLElement | null;
if (ledStopBtn) { ledStopBtn.style.display = ledRunning ? '' : 'none'; if (!ledStopBtn.title) { ledStopBtn.title = t('targets.stop_all.button'); ledStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } }
if (kcStopBtn) { kcStopBtn.style.display = kcRunning ? '' : 'none'; if (!kcStopBtn.title) { kcStopBtn.title = t('targets.stop_all.button'); kcStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } }
// Patch volatile metrics in-place (avoids full card replacement on polls)
for (const tgt of ledTargets) {
if (tgt.state && tgt.state.processing) _patchTargetMetrics(tgt);
}
for (const tgt of kcTargets) {
if (tgt.state && tgt.state.processing) patchKCTargetMetrics(tgt);
for (const tgt of haLightTargets) {
if (tgt.state && tgt.state.processing) patchHALightTargetMetrics(tgt);
}
// Attach event listeners and fetch brightness for device cards
@@ -806,20 +760,6 @@ export async function loadTargetsTab() {
}
}
// Manage KC WebSockets: connect for processing, disconnect for stopped
const processingKCIds = new Set();
kcTargets.forEach(target => {
if (target.state && target.state.processing) {
processingKCIds.add(target.id);
if (!kcWebSockets[target.id]) {
connectKCWebSocket(target.id);
}
}
});
Object.keys(kcWebSockets).forEach(id => {
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
});
// Auto-disconnect LED preview WebSockets for targets that stopped
const processingLedIds = new Set();
ledTargets.forEach(target => {
@@ -847,7 +787,7 @@ export async function loadTargetsTab() {
}
// Push FPS samples and create/update charts for running targets
const allTargets = [...ledTargets, ...kcTargets];
const allTargets = [...ledTargets];
const runningIds = new Set();
const runningTargetIds = allTargets.filter(t => t.state?.processing).map(t => t.id);
@@ -1168,12 +1108,6 @@ export async function stopAllLedTargets() {
await _stopAllByType('led');
}
export async function stopAllKCTargets() {
const confirmed = await showConfirm(t('confirm.stop_all'));
if (!confirmed) return;
await _stopAllByType('key_colors');
}
async function _stopAllByType(targetType: any) {
try {
const [allTargets, statesResp] = await Promise.all([