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
@@ -2,17 +2,16 @@
* HA Light Targets — editor, cards, CRUD for Home Assistant light output targets.
*/
import { _cachedHASources, haSourcesCache, colorStripSourcesCache, outputTargetsCache } from '../core/state.ts';
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 } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH } from '../core/icons.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';
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>`;
@@ -22,6 +21,7 @@ const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
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
@@ -33,6 +33,7 @@ class HALightEditorModal extends Modal {
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();
}
@@ -207,6 +208,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
const [haSources, cssSources] = await Promise.all([
haSourcesCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch().catch(() => {}),
]);
_editorCssSources = cssSources;
@@ -307,6 +309,23 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
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') });
@@ -343,10 +362,13 @@ export async function saveHALightEditor(): Promise<void> {
// 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,
@@ -407,13 +429,28 @@ export async function cloneHALightTarget(targetId: string): Promise<void> {
// ── Card rendering ──
export function createHALightTargetCard(target: any, haSourceMap: Record<string, any> = {}, cssSourceMap: Record<string, any> = {}): string {
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 cssName = cssSource ? escapeHtml(cssSource.name) : target.color_strip_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',
@@ -426,13 +463,32 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
<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${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>` : ''}`,
${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>`,
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}
@@ -442,6 +498,24 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
});
}
// ── 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> = {