feat: HA light target live color preview — per-entity swatches via WebSocket
Lint & Test / test (push) Successful in 1m24s
Lint & Test / test (push) Successful in 1m24s
- 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
This commit is contained in:
@@ -1553,6 +1553,35 @@
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* HA Light color swatches */
|
||||
.ha-light-swatches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.ha-light-swatch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ha-light-swatch .swatch-color {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
.ha-light-swatch .swatch-label {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-remove-mapping:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
closeTutorial, tutorialNext, tutorialPrev,
|
||||
} from './features/tutorials.ts';
|
||||
|
||||
// Layer 4: devices, dashboard, streams, kc-targets, pattern-templates, automations
|
||||
// Layer 4: devices, dashboard, streams, pattern-templates, automations
|
||||
import {
|
||||
showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal,
|
||||
saveDeviceSettings, updateBrightnessLabel, saveCardBrightness,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fetchWithAuth, escapeHtml } from './api.ts';
|
||||
import { t } from './i18n.ts';
|
||||
import { navigateToCard } from './navigation.ts';
|
||||
import {
|
||||
getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon,
|
||||
getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon,
|
||||
ICON_DEVICE, ICON_TARGET, ICON_AUTOMATION, ICON_VALUE_SOURCE, ICON_SCENE,
|
||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CSPT, ICON_CLOCK,
|
||||
} from './icons.ts';
|
||||
@@ -46,17 +46,10 @@ function _buildItems(results: any[], states: any = {}) {
|
||||
|
||||
_mapEntities(targets, tgt => {
|
||||
const running = !!states[tgt.id]?.processing;
|
||||
if (tgt.target_type === 'key_colors') {
|
||||
items.push({
|
||||
name: tgt.name, detail: 'key_colors', group: 'kc_targets', icon: getTargetTypeIcon('key_colors'),
|
||||
nav: ['targets', 'kc-targets', 'kc-targets', 'data-kc-target-id', tgt.id], running,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
name: tgt.name, detail: tgt.target_type, group: 'targets', icon: ICON_TARGET,
|
||||
nav: ['targets', 'led-targets', 'led-targets', 'data-target-id', tgt.id], running,
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
name: tgt.name, detail: tgt.target_type, group: 'targets', icon: ICON_TARGET,
|
||||
nav: ['targets', 'led-targets', 'led-targets', 'data-target-id', tgt.id], running,
|
||||
});
|
||||
// Action item: toggle start/stop
|
||||
const actionItem: any = {
|
||||
name: tgt.name, group: 'actions',
|
||||
@@ -219,7 +212,7 @@ async function _fetchAllEntities() {
|
||||
|
||||
const _groupOrder = [
|
||||
'actions',
|
||||
'devices', 'targets', 'kc_targets', 'css', 'cspt', 'automations',
|
||||
'devices', 'targets', 'css', 'cspt', 'automations',
|
||||
'streams', 'capture_templates', 'pp_templates', 'pattern_templates',
|
||||
'audio', 'value', 'scenes', 'sync_clocks',
|
||||
];
|
||||
|
||||
@@ -105,7 +105,7 @@ const SUBTYPE_ICONS = {
|
||||
adaptive_time: P.clock, adaptive_scene: P.cloudSun, daylight: P.sun,
|
||||
},
|
||||
audio_source: { mono: P.mic, multichannel: P.volume2 },
|
||||
output_target: { led: P.lightbulb, wled: P.lightbulb, key_colors: P.palette },
|
||||
output_target: { led: P.lightbulb, wled: P.lightbulb, ha_light: P.lightbulb },
|
||||
};
|
||||
|
||||
function svgEl(tag: string, attrs: Record<string, string | number> = {}): SVGElement {
|
||||
@@ -413,7 +413,7 @@ function _createOverlay(node: GraphNode, nodeWidth: number, callbacks: NodeCallb
|
||||
}
|
||||
|
||||
// Test button for applicable kinds
|
||||
if (TEST_KINDS.has(node.kind) || (node.kind === 'output_target' && node.subtype === 'key_colors')) {
|
||||
if (TEST_KINDS.has(node.kind)) {
|
||||
btns.push({ svgPath: P.flaskConical, action: 'test', cls: '' });
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import * as P from './icon-paths.ts';
|
||||
const _svg = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
// ── Type-resolution maps (private) ──────────────────────────
|
||||
const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb), key_colors: _svg(P.palette) };
|
||||
const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb), ha_light: _svg(P.lightbulb) };
|
||||
const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image), video: _svg(P.film) };
|
||||
const _colorStripTypeIcons = {
|
||||
picture_advanced: _svg(P.monitor),
|
||||
|
||||
@@ -24,18 +24,6 @@ export function setAuthRequired(v: boolean) { authRequired = v; }
|
||||
export let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||
export function setRefreshInterval(v: ReturnType<typeof setInterval> | null) { refreshInterval = v; }
|
||||
|
||||
export let kcTestAutoRefresh: ReturnType<typeof setInterval> | null = null;
|
||||
export function setKcTestAutoRefresh(v: ReturnType<typeof setInterval> | null) { kcTestAutoRefresh = v; }
|
||||
|
||||
export let kcTestTargetId: string | null = null;
|
||||
export function setKcTestTargetId(v: string | null) { kcTestTargetId = v; }
|
||||
|
||||
export let kcTestWs: WebSocket | null = null;
|
||||
export function setKcTestWs(v: WebSocket | null) { kcTestWs = v; }
|
||||
|
||||
export let kcTestFps = 3;
|
||||
export function setKcTestFps(v: number) { kcTestFps = v; }
|
||||
|
||||
export let _cachedDisplays: Display[] | null = null;
|
||||
|
||||
export let _displayPickerCallback: ((index: number, display?: Display | null) => void) | null = null;
|
||||
@@ -127,13 +115,6 @@ export function set_lastValidatedImageSource(v: string) { _lastValidatedImageSou
|
||||
export let _targetEditorDevices: Device[] = [];
|
||||
export function set_targetEditorDevices(v: Device[]) { _targetEditorDevices = v; }
|
||||
|
||||
// KC editor state
|
||||
export let _kcNameManuallyEdited = false;
|
||||
export function set_kcNameManuallyEdited(v: boolean) { _kcNameManuallyEdited = v; }
|
||||
|
||||
// KC WebSockets
|
||||
export const kcWebSockets: Record<string, WebSocket> = {};
|
||||
|
||||
// LED Preview WebSockets
|
||||
export const ledPreviewWebSockets: Record<string, WebSocket> = {};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* UI utilities — modal helpers, lightbox, toast, confirm.
|
||||
*/
|
||||
|
||||
import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, kcTestWs, setKcTestWs, confirmResolve, setConfirmResolve } from './state.ts';
|
||||
import { confirmResolve, setConfirmResolve } from './state.ts';
|
||||
import { API_BASE, getHeaders } from './api.ts';
|
||||
import { t } from './i18n.ts';
|
||||
|
||||
@@ -101,8 +101,6 @@ export function openLightbox(imageSrc: string, statsHtml?: string) {
|
||||
|
||||
export function closeLightbox(event?: Event) {
|
||||
if (event && event.target && (event.target as HTMLElement).closest('.lightbox-content')) return;
|
||||
// Stop KC test WS if running
|
||||
stopKCTestAutoRefresh();
|
||||
const lightbox = document.getElementById('image-lightbox')!;
|
||||
lightbox.classList.remove('active');
|
||||
const img = document.getElementById('lightbox-image') as HTMLImageElement;
|
||||
@@ -115,18 +113,6 @@ export function closeLightbox(event?: Event) {
|
||||
unlockBody();
|
||||
}
|
||||
|
||||
export function stopKCTestAutoRefresh() {
|
||||
if (kcTestAutoRefresh) {
|
||||
clearInterval(kcTestAutoRefresh);
|
||||
setKcTestAutoRefresh(null);
|
||||
}
|
||||
if (kcTestWs) {
|
||||
try { kcTestWs.close(); } catch (_) {}
|
||||
setKcTestWs(null);
|
||||
}
|
||||
setKcTestTargetId(null);
|
||||
}
|
||||
|
||||
export function showToast(message: string, type = 'info') {
|
||||
const toast = document.getElementById('toast')!;
|
||||
toast.textContent = message;
|
||||
|
||||
@@ -1528,7 +1528,7 @@ function _onTestNode(node: any) {
|
||||
value_source: () => _w.testValueSource?.(node.id),
|
||||
color_strip_source: () => _w.testColorStrip?.(node.id),
|
||||
cspt: () => _w.testCSPT?.(node.id),
|
||||
output_target: () => _w.testKCTarget?.(node.id),
|
||||
output_target: undefined,
|
||||
};
|
||||
fnMap[node.kind]?.();
|
||||
}
|
||||
|
||||
@@ -488,6 +488,9 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
|
||||
<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: `
|
||||
@@ -560,6 +563,78 @@ export function initHALightTargetDelegation(container: HTMLElement): void {
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
|
||||
@@ -1,841 +0,0 @@
|
||||
/**
|
||||
* Key Colors targets — cards, test lightbox, editor, WebSocket live colors.
|
||||
*/
|
||||
|
||||
import {
|
||||
kcTestAutoRefresh, setKcTestAutoRefresh,
|
||||
kcTestTargetId, setKcTestTargetId,
|
||||
kcTestWs, setKcTestWs,
|
||||
kcTestFps, setKcTestFps,
|
||||
_kcNameManuallyEdited, set_kcNameManuallyEdited,
|
||||
kcWebSockets,
|
||||
PATTERN_RECT_BORDERS,
|
||||
_cachedValueSources, valueSourcesCache, streamsCache,
|
||||
outputTargetsCache, patternTemplatesCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { lockBody, showToast, showConfirm, formatUptime, formatCompact, desktopFocus } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import {
|
||||
getValueSourceIcon, getPictureSourceIcon,
|
||||
ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_START, ICON_STOP,
|
||||
ICON_LINK_SOURCE, ICON_PATTERN_TEMPLATE, ICON_FPS, ICON_PALETTE,
|
||||
} from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import type { OutputTarget } from '../types.ts';
|
||||
|
||||
let _kcTagsInput: any = null;
|
||||
|
||||
class KCEditorModal extends Modal {
|
||||
constructor() {
|
||||
super('kc-editor-modal');
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: (document.getElementById('kc-editor-name') as HTMLInputElement).value,
|
||||
source: (document.getElementById('kc-editor-source') as HTMLSelectElement).value,
|
||||
fps: (document.getElementById('kc-editor-fps') as HTMLInputElement).value,
|
||||
interpolation: (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value,
|
||||
smoothing: (document.getElementById('kc-editor-smoothing') as HTMLInputElement).value,
|
||||
patternTemplateId: (document.getElementById('kc-editor-pattern-template') as HTMLSelectElement).value,
|
||||
brightness_vs: (document.getElementById('kc-editor-brightness-vs') as HTMLSelectElement).value,
|
||||
tags: JSON.stringify(_kcTagsInput ? _kcTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const kcEditorModal = new KCEditorModal();
|
||||
|
||||
/* ── Visual selectors ─────────────────────────────────────────── */
|
||||
|
||||
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
let _kcColorModeIconSelect: any = null;
|
||||
let _kcSourceEntitySelect: any = null;
|
||||
let _kcPatternEntitySelect: any = null;
|
||||
let _kcBrightnessEntitySelect: any = null;
|
||||
|
||||
// Inline SVG previews for color modes
|
||||
const _COLOR_MODE_SVG = {
|
||||
average: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="52" height="16" rx="3" opacity="0.3" fill="currentColor"/><path d="M30 8v8" stroke-width="1.5"/><path d="M20 10v4" stroke-width="1.5"/><path d="M40 10v4" stroke-width="1.5"/></svg>',
|
||||
median: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="14" width="8" height="6" rx="1" fill="currentColor" opacity="0.3"/><rect x="18" y="8" width="8" height="12" rx="1" fill="currentColor" opacity="0.5"/><rect x="30" y="4" width="8" height="16" rx="1" fill="currentColor" opacity="0.7"/><rect x="42" y="10" width="8" height="10" rx="1" fill="currentColor" opacity="0.4"/></svg>',
|
||||
dominant: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="20" cy="12" r="4" opacity="0.2" fill="currentColor"/><circle cx="30" cy="12" r="8" fill="currentColor" opacity="0.6"/><circle cx="42" cy="12" r="3" opacity="0.15" fill="currentColor"/></svg>',
|
||||
};
|
||||
|
||||
function _ensureColorModeIconSelect() {
|
||||
const sel = document.getElementById('kc-editor-interpolation');
|
||||
if (!sel) return;
|
||||
const items = [
|
||||
{ value: 'average', icon: _COLOR_MODE_SVG.average, label: t('kc.interpolation.average'), desc: t('kc.interpolation.average.desc') },
|
||||
{ value: 'median', icon: _COLOR_MODE_SVG.median, label: t('kc.interpolation.median'), desc: t('kc.interpolation.median.desc') },
|
||||
{ value: 'dominant', icon: _COLOR_MODE_SVG.dominant, label: t('kc.interpolation.dominant'), desc: t('kc.interpolation.dominant.desc') },
|
||||
];
|
||||
if (_kcColorModeIconSelect) { _kcColorModeIconSelect.updateItems(items); return; }
|
||||
_kcColorModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any);
|
||||
}
|
||||
|
||||
function _ensureSourceEntitySelect(sources: any) {
|
||||
const sel = document.getElementById('kc-editor-source');
|
||||
if (!sel) return;
|
||||
if (_kcSourceEntitySelect) _kcSourceEntitySelect.destroy();
|
||||
if (sources.length > 0) {
|
||||
_kcSourceEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => sources.map((s: any) => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
icon: getPictureSourceIcon(s.stream_type),
|
||||
desc: s.stream_type,
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
function _ensurePatternEntitySelect(patTemplates: any) {
|
||||
const sel = document.getElementById('kc-editor-pattern-template');
|
||||
if (!sel) return;
|
||||
if (_kcPatternEntitySelect) _kcPatternEntitySelect.destroy();
|
||||
if (patTemplates.length > 0) {
|
||||
_kcPatternEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => patTemplates.map((pt: any) => {
|
||||
const rectCount = (pt.rectangles || []).length;
|
||||
return {
|
||||
value: pt.id,
|
||||
label: pt.name,
|
||||
icon: _icon(P.fileText),
|
||||
desc: `${rectCount} rect${rectCount !== 1 ? 's' : ''}`,
|
||||
};
|
||||
}),
|
||||
placeholder: t('palette.search'),
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
function _ensureBrightnessEntitySelect() {
|
||||
const sel = document.getElementById('kc-editor-brightness-vs');
|
||||
if (!sel) return;
|
||||
if (_kcBrightnessEntitySelect) _kcBrightnessEntitySelect.destroy();
|
||||
if (_cachedValueSources.length > 0) {
|
||||
_kcBrightnessEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => {
|
||||
const items = [{ value: '', label: t('kc.brightness_vs.none'), icon: _icon(P.sunDim), desc: '' }];
|
||||
return items.concat(_cachedValueSources.map((vs: any) => ({
|
||||
value: vs.id,
|
||||
label: vs.name,
|
||||
icon: getValueSourceIcon(vs.source_type),
|
||||
desc: vs.source_type,
|
||||
})));
|
||||
},
|
||||
placeholder: t('palette.search'),
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
export function patchKCTargetMetrics(target: any) {
|
||||
const card = document.querySelector(`[data-kc-target-id="${CSS.escape(target.id)}"]`);
|
||||
if (!card) return;
|
||||
const state = target.state || {};
|
||||
const metrics = target.metrics || {};
|
||||
|
||||
const fpsActual = card.querySelector('[data-tm="fps-actual"]');
|
||||
if (fpsActual) fpsActual.textContent = state.fps_actual?.toFixed(1) || '0.0';
|
||||
|
||||
const fpsCurrent = card.querySelector('[data-tm="fps-current"]');
|
||||
if (fpsCurrent) fpsCurrent.textContent = state.fps_current ?? '-';
|
||||
|
||||
const fpsTarget = card.querySelector('[data-tm="fps-target"]');
|
||||
if (fpsTarget) fpsTarget.textContent = state.fps_target || 0;
|
||||
|
||||
const frames = card.querySelector('[data-tm="frames"]') as HTMLElement;
|
||||
if (frames) { frames.textContent = formatCompact(metrics.frames_processed || 0); frames.title = String(metrics.frames_processed || 0); }
|
||||
|
||||
const keepalive = card.querySelector('[data-tm="keepalive"]') as HTMLElement;
|
||||
if (keepalive) { keepalive.textContent = formatCompact(state.frames_keepalive ?? 0); keepalive.title = String(state.frames_keepalive ?? 0); }
|
||||
|
||||
const errors = card.querySelector('[data-tm="errors"]') as HTMLElement;
|
||||
if (errors) { errors.textContent = formatCompact(metrics.errors_count || 0); errors.title = String(metrics.errors_count || 0); }
|
||||
|
||||
const uptime = card.querySelector('[data-tm="uptime"]');
|
||||
if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds);
|
||||
|
||||
const timing = card.querySelector('[data-tm="timing"]');
|
||||
if (timing && state.timing_total_ms != null) {
|
||||
timing.innerHTML = `
|
||||
<div class="timing-header">
|
||||
<div class="metric-label">${t('device.metrics.timing')}</div>
|
||||
<div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
|
||||
</div>
|
||||
<div class="timing-bar">
|
||||
<span class="timing-seg timing-extract" style="flex:${state.timing_calc_colors_ms}" title="calc ${state.timing_calc_colors_ms}ms"></span>
|
||||
<span class="timing-seg timing-smooth" style="flex:${state.timing_smooth_ms || 0.1}" title="smooth ${state.timing_smooth_ms}ms"></span>
|
||||
<span class="timing-seg timing-send" style="flex:${state.timing_broadcast_ms}" title="broadcast ${state.timing_broadcast_ms}ms"></span>
|
||||
</div>
|
||||
<div class="timing-legend">
|
||||
<span class="timing-legend-item"><span class="timing-dot timing-extract"></span>calc ${state.timing_calc_colors_ms}ms</span>
|
||||
<span class="timing-legend-item"><span class="timing-dot timing-smooth"></span>smooth ${state.timing_smooth_ms}ms</span>
|
||||
<span class="timing-legend-item"><span class="timing-dot timing-send"></span>broadcast ${state.timing_broadcast_ms}ms</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createKCTargetCard(target: OutputTarget & { state?: any; metrics?: any; latestColors?: any }, sourceMap: Record<string, any>, patternTemplateMap: Record<string, any>, valueSourceMap: Record<string, any>) {
|
||||
const state = target.state || {};
|
||||
const kcSettings = target.key_colors_settings ?? {} as Partial<import('../types.ts').KeyColorsSettings>;
|
||||
|
||||
const isProcessing = state.processing || false;
|
||||
const brightness = kcSettings.brightness ?? 1.0;
|
||||
const brightnessInt = Math.round(brightness * 255);
|
||||
|
||||
const source = sourceMap[target.picture_source_id!];
|
||||
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
|
||||
const patTmpl = patternTemplateMap[kcSettings.pattern_template_id!];
|
||||
const patternName = patTmpl ? patTmpl.name : 'No pattern';
|
||||
const rectCount = patTmpl ? (patTmpl.rectangles || []).length : 0;
|
||||
|
||||
const bvsId = kcSettings.brightness_value_source_id || '';
|
||||
const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null;
|
||||
|
||||
// Render initial color swatches from pre-fetched REST data
|
||||
let swatchesHtml = '';
|
||||
const latestColors = target.latestColors && target.latestColors.colors;
|
||||
if (isProcessing && latestColors && Object.keys(latestColors).length > 0) {
|
||||
swatchesHtml = Object.entries(latestColors).map(([name, color]: [string, any]) => `
|
||||
<div class="kc-swatch">
|
||||
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
|
||||
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} else if (isProcessing) {
|
||||
swatchesHtml = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
|
||||
}
|
||||
|
||||
return wrapCard({
|
||||
dataAttr: 'data-kc-target-id',
|
||||
id: target.id,
|
||||
removeOnclick: `deleteKCTarget('${target.id}')`,
|
||||
removeTitle: t('common.delete'),
|
||||
content: `
|
||||
<div class="card-header">
|
||||
<div class="card-title" title="${escapeHtml(target.name)}">
|
||||
<span class="card-title-text">${escapeHtml(target.name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop${source ? ' stream-card-link' : ''}" title="${t('kc.source')}"${source ? ` onclick="event.stopPropagation(); navigateToCard('streams','${source.stream_type === 'static_image' ? 'static_image' : source.stream_type === 'processed' ? 'processed' : 'raw'}','${source.stream_type === 'static_image' ? 'static-streams' : source.stream_type === 'processed' ? 'proc-streams' : 'raw-streams'}','data-stream-id','${target.picture_source_id}')"` : ''}>${ICON_LINK_SOURCE} ${escapeHtml(sourceName)}</span>
|
||||
<span class="stream-card-prop${patTmpl ? ' stream-card-link' : ''}" title="${t('kc.pattern_template')}"${patTmpl ? ` onclick="event.stopPropagation(); navigateToCard('targets','kc-patterns','kc-patterns','data-pattern-template-id','${kcSettings.pattern_template_id}')"` : ''}>${ICON_PATTERN_TEMPLATE} ${escapeHtml(patternName)}</span>
|
||||
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
|
||||
<span class="stream-card-prop" title="${t('kc.fps')}">${ICON_FPS} ${kcSettings.fps ?? 10}</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)}
|
||||
<div class="brightness-control" data-kc-brightness-wrap="${target.id}">
|
||||
<input type="range" class="brightness-slider" min="0" max="255"
|
||||
value="${brightnessInt}" data-kc-brightness="${target.id}"
|
||||
oninput="updateKCBrightnessLabel('${target.id}', this.value)"
|
||||
onchange="saveKCBrightness('${target.id}', this.value)"
|
||||
title="${Math.round(brightness * 100)}%">
|
||||
</div>
|
||||
<div id="kc-swatches-${target.id}" class="kc-color-swatches">
|
||||
${swatchesHtml}
|
||||
</div>
|
||||
${isProcessing ? `
|
||||
<div class="card-content">
|
||||
<div class="metrics-grid">
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.actual_fps')}</div>
|
||||
<div class="metric-value" data-tm="fps-actual">---</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.current_fps')}</div>
|
||||
<div class="metric-value" data-tm="fps-current">---</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.target_fps')}</div>
|
||||
<div class="metric-value" data-tm="fps-target">---</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.frames')}</div>
|
||||
<div class="metric-value" data-tm="frames">---</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.keepalive')}</div>
|
||||
<div class="metric-value" data-tm="keepalive">---</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.errors')}</div>
|
||||
<div class="metric-value" data-tm="errors">---</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.uptime')}</div>
|
||||
<div class="metric-value" data-tm="uptime">---</div>
|
||||
</div>
|
||||
</div>
|
||||
${state.timing_total_ms != null ? `
|
||||
<div class="timing-breakdown" data-tm="timing"></div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}`,
|
||||
actions: `
|
||||
${isProcessing ? `
|
||||
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('targets.button.stop')}">
|
||||
${ICON_STOP}
|
||||
</button>
|
||||
` : `
|
||||
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('targets.button.start')}">
|
||||
${ICON_START}
|
||||
</button>
|
||||
`}
|
||||
<button class="btn btn-icon btn-secondary" onclick="testKCTarget('${target.id}')" title="${t('kc.test')}">
|
||||
${ICON_TEST}
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneKCTarget('${target.id}')" title="${t('common.clone')}">
|
||||
${ICON_CLONE}
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}">
|
||||
${ICON_EDIT}
|
||||
</button>`,
|
||||
});
|
||||
}
|
||||
|
||||
// ===== KEY COLORS TEST =====
|
||||
|
||||
function _openKCTestWs(targetId: any, fps: any, previewWidth = 480) {
|
||||
// Close any existing WS
|
||||
if (kcTestWs) {
|
||||
try { kcTestWs.close(); } catch (_) {}
|
||||
setKcTestWs(null);
|
||||
}
|
||||
|
||||
const key = localStorage.getItem('wled_api_key');
|
||||
if (!key) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/output-targets/${targetId}/test/ws?token=${encodeURIComponent(key)}&fps=${fps}&preview_width=${previewWidth}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'frame') {
|
||||
// Hide spinner on first frame
|
||||
const spinner = document.querySelector('.lightbox-spinner') as HTMLElement;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
displayKCTestResults(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('KC test WS parse error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
setKcTestWs(null);
|
||||
// Only show error if closed unexpectedly (not a normal close)
|
||||
if (ev.code !== 1000 && ev.code !== 1001 && kcTestTargetId) {
|
||||
const reason = ev.reason || t('kc.test.ws_closed');
|
||||
showToast(t('kc.test.error') + ': ' + reason, 'error');
|
||||
// Close lightbox on fatal errors (auth, bad target, etc.)
|
||||
if (ev.code === 4001 || ev.code === 4003 || ev.code === 4004) {
|
||||
if (typeof window.closeLightbox === 'function') window.closeLightbox();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose will fire after onerror; no need to handle here
|
||||
};
|
||||
|
||||
setKcTestWs(ws);
|
||||
setKcTestTargetId(targetId);
|
||||
}
|
||||
|
||||
export async function testKCTarget(targetId: any) {
|
||||
setKcTestTargetId(targetId);
|
||||
|
||||
// Show lightbox immediately with a spinner
|
||||
const lightbox = document.getElementById('image-lightbox')!;
|
||||
const lbImg = document.getElementById('lightbox-image') as HTMLImageElement;
|
||||
const statsEl = document.getElementById('lightbox-stats') as HTMLElement;
|
||||
lbImg.style.display = 'none';
|
||||
lbImg.src = '';
|
||||
statsEl.style.display = 'none';
|
||||
|
||||
// Insert spinner if not already present
|
||||
let spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement;
|
||||
if (!spinner) {
|
||||
spinner = document.createElement('div');
|
||||
spinner.className = 'lightbox-spinner loading-spinner';
|
||||
lightbox.querySelector('.lightbox-content')!.prepend(spinner);
|
||||
}
|
||||
spinner.style.display = '';
|
||||
|
||||
// Hide controls — KC test streams automatically
|
||||
const refreshBtn = document.getElementById('lightbox-auto-refresh') as HTMLElement;
|
||||
if (refreshBtn) refreshBtn.style.display = 'none';
|
||||
const fpsSelect = document.getElementById('lightbox-fps-select') as HTMLElement;
|
||||
if (fpsSelect) fpsSelect.style.display = 'none';
|
||||
|
||||
lightbox.classList.add('active');
|
||||
lockBody();
|
||||
|
||||
// Use same FPS from CSS test settings and dynamic preview resolution
|
||||
const fps = parseInt(localStorage.getItem('css_test_fps')!) || 15;
|
||||
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
|
||||
_openKCTestWs(targetId, fps, previewWidth);
|
||||
}
|
||||
|
||||
export function stopKCTestAutoRefresh() {
|
||||
if (kcTestAutoRefresh) {
|
||||
clearInterval(kcTestAutoRefresh);
|
||||
setKcTestAutoRefresh(null);
|
||||
}
|
||||
if (kcTestWs) {
|
||||
try { kcTestWs.close(1000, 'lightbox closed'); } catch (_) {}
|
||||
setKcTestWs(null);
|
||||
}
|
||||
setKcTestTargetId(null);
|
||||
}
|
||||
|
||||
export function displayKCTestResults(result: any) {
|
||||
const srcImg = new window.Image();
|
||||
srcImg.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = srcImg.width;
|
||||
canvas.height = srcImg.height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
// Draw captured frame
|
||||
ctx.drawImage(srcImg, 0, 0);
|
||||
|
||||
const w = srcImg.width;
|
||||
const h = srcImg.height;
|
||||
|
||||
// Draw each rectangle with extracted color overlay
|
||||
result.rectangles.forEach((rect: any, i: number) => {
|
||||
const px = rect.x * w;
|
||||
const py = rect.y * h;
|
||||
const pw = rect.width * w;
|
||||
const ph = rect.height * h;
|
||||
|
||||
const color = rect.color;
|
||||
const borderColor = PATTERN_RECT_BORDERS[i % PATTERN_RECT_BORDERS.length];
|
||||
|
||||
// Semi-transparent fill with the extracted color
|
||||
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.3)`;
|
||||
ctx.fillRect(px, py, pw, ph);
|
||||
|
||||
// Border using pattern colors for distinction
|
||||
ctx.strokeStyle = borderColor;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(px, py, pw, ph);
|
||||
|
||||
// Color swatch in top-left corner of rect
|
||||
const swatchSize = Math.max(16, Math.min(32, pw * 0.15));
|
||||
ctx.fillStyle = color.hex;
|
||||
ctx.fillRect(px + 4, py + 4, swatchSize, swatchSize);
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(px + 4, py + 4, swatchSize, swatchSize);
|
||||
|
||||
// Name label with shadow for readability
|
||||
const fontSize = Math.max(12, Math.min(18, pw * 0.06));
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
const labelX = px + swatchSize + 10;
|
||||
const labelY = py + 4 + swatchSize / 2 + fontSize / 3;
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.8)';
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillText(rect.name, labelX, labelY);
|
||||
|
||||
// Hex label below name
|
||||
ctx.font = `${fontSize - 2}px monospace`;
|
||||
ctx.fillText(color.hex, labelX, labelY + fontSize + 2);
|
||||
ctx.shadowBlur = 0;
|
||||
});
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.92);
|
||||
|
||||
// Build stats HTML
|
||||
let statsHtml = `<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">`;
|
||||
statsHtml += `<span style="opacity:0.7;margin-right:8px;">${escapeHtml(result.pattern_template_name)} \u2022 ${escapeHtml(result.interpolation_mode)}</span>`;
|
||||
result.rectangles.forEach((rect: any) => {
|
||||
const c = rect.color;
|
||||
statsHtml += `<div style="display:flex;align-items:center;gap:4px;">`;
|
||||
statsHtml += `<div style="width:14px;height:14px;border-radius:3px;border:1px solid rgba(255,255,255,0.4);background:${c.hex};"></div>`;
|
||||
statsHtml += `<span style="font-size:0.85em;">${escapeHtml(rect.name)} <code>${c.hex}</code></span>`;
|
||||
statsHtml += `</div>`;
|
||||
});
|
||||
statsHtml += `</div>`;
|
||||
|
||||
// Hide spinner, show result in the already-open lightbox
|
||||
const spinner = document.querySelector('.lightbox-spinner') as HTMLElement;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
|
||||
const lbImg = document.getElementById('lightbox-image') as HTMLImageElement;
|
||||
const statsEl = document.getElementById('lightbox-stats') as HTMLElement;
|
||||
lbImg.src = dataUrl;
|
||||
lbImg.style.display = '';
|
||||
statsEl.innerHTML = statsHtml;
|
||||
statsEl.style.display = '';
|
||||
};
|
||||
srcImg.src = result.image;
|
||||
}
|
||||
|
||||
// ===== KEY COLORS EDITOR =====
|
||||
|
||||
function _autoGenerateKCName() {
|
||||
if (_kcNameManuallyEdited) return;
|
||||
if ((document.getElementById('kc-editor-id') as HTMLInputElement).value) return;
|
||||
const sourceSelect = document.getElementById('kc-editor-source') as HTMLSelectElement;
|
||||
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
|
||||
if (!sourceName) return;
|
||||
const mode = (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value || 'average';
|
||||
const modeName = t(`kc.interpolation.${mode}`);
|
||||
const patSelect = document.getElementById('kc-editor-pattern-template') as HTMLSelectElement;
|
||||
const patName = patSelect.selectedOptions[0]?.dataset?.name || '';
|
||||
(document.getElementById('kc-editor-name') as HTMLInputElement).value = `${sourceName} \u00b7 ${patName} (${modeName})`;
|
||||
}
|
||||
|
||||
function _populateKCBrightnessVsDropdown(selectedId = '') {
|
||||
const sel = document.getElementById('kc-editor-brightness-vs') as HTMLSelectElement;
|
||||
// Keep the first "None" option, remove the rest
|
||||
while (sel.options.length > 1) sel.remove(1);
|
||||
_cachedValueSources.forEach((vs: any) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = vs.id;
|
||||
opt.textContent = vs.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.value = selectedId || '';
|
||||
_ensureBrightnessEntitySelect();
|
||||
}
|
||||
|
||||
export async function showKCEditor(targetId: any = null, cloneData: any = null) {
|
||||
try {
|
||||
// Load sources, pattern templates, and value sources in parallel
|
||||
const [sources, patTemplates, valueSources] = await Promise.all([
|
||||
streamsCache.fetch().catch((): any[] => []),
|
||||
patternTemplatesCache.fetch().catch((): any[] => []),
|
||||
valueSourcesCache.fetch(),
|
||||
]);
|
||||
|
||||
// Populate source select
|
||||
const sourceSelect = document.getElementById('kc-editor-source') as HTMLSelectElement;
|
||||
sourceSelect.innerHTML = '';
|
||||
sources.forEach((s: any) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.dataset.name = s.name;
|
||||
opt.textContent = s.name;
|
||||
sourceSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Populate pattern template select
|
||||
const patSelect = document.getElementById('kc-editor-pattern-template') as HTMLSelectElement;
|
||||
patSelect.innerHTML = '';
|
||||
patTemplates.forEach((pt: any) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = pt.id;
|
||||
opt.dataset.name = pt.name;
|
||||
const rectCount = (pt.rectangles || []).length;
|
||||
opt.textContent = `${pt.name} (${rectCount} rect${rectCount !== 1 ? 's' : ''})`;
|
||||
patSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Set up visual selectors
|
||||
_ensureColorModeIconSelect();
|
||||
_ensureSourceEntitySelect(sources);
|
||||
_ensurePatternEntitySelect(patTemplates);
|
||||
|
||||
let _editorTags: any[] = [];
|
||||
if (targetId) {
|
||||
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
const target = await resp.json();
|
||||
_editorTags = target.tags || [];
|
||||
const kcSettings = target.key_colors_settings || {};
|
||||
|
||||
(document.getElementById('kc-editor-id') as HTMLInputElement).value = target.id;
|
||||
(document.getElementById('kc-editor-name') as HTMLInputElement).value = target.name;
|
||||
sourceSelect.value = target.picture_source_id || '';
|
||||
(document.getElementById('kc-editor-fps') as HTMLInputElement).value = kcSettings.fps ?? 10;
|
||||
(document.getElementById('kc-editor-fps-value') as HTMLElement).textContent = kcSettings.fps ?? 10;
|
||||
(document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value = kcSettings.interpolation_mode ?? 'average';
|
||||
if (_kcColorModeIconSelect) _kcColorModeIconSelect.setValue(kcSettings.interpolation_mode ?? 'average');
|
||||
(document.getElementById('kc-editor-smoothing') as HTMLInputElement).value = kcSettings.smoothing ?? 0.3;
|
||||
(document.getElementById('kc-editor-smoothing-value') as HTMLElement).textContent = kcSettings.smoothing ?? 0.3;
|
||||
patSelect.value = kcSettings.pattern_template_id || '';
|
||||
_populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || '');
|
||||
(document.getElementById('kc-editor-title') as HTMLElement).innerHTML = `${ICON_PALETTE} ${t('kc.edit')}`;
|
||||
} else if (cloneData) {
|
||||
_editorTags = cloneData.tags || [];
|
||||
const kcSettings = cloneData.key_colors_settings || {};
|
||||
(document.getElementById('kc-editor-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('kc-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
|
||||
sourceSelect.value = cloneData.picture_source_id || '';
|
||||
(document.getElementById('kc-editor-fps') as HTMLInputElement).value = kcSettings.fps ?? 10;
|
||||
(document.getElementById('kc-editor-fps-value') as HTMLElement).textContent = kcSettings.fps ?? 10;
|
||||
(document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value = kcSettings.interpolation_mode ?? 'average';
|
||||
if (_kcColorModeIconSelect) _kcColorModeIconSelect.setValue(kcSettings.interpolation_mode ?? 'average');
|
||||
(document.getElementById('kc-editor-smoothing') as HTMLInputElement).value = kcSettings.smoothing ?? 0.3;
|
||||
(document.getElementById('kc-editor-smoothing-value') as HTMLElement).textContent = kcSettings.smoothing ?? 0.3;
|
||||
patSelect.value = kcSettings.pattern_template_id || '';
|
||||
_populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || '');
|
||||
(document.getElementById('kc-editor-title') as HTMLElement).innerHTML = `${ICON_PALETTE} ${t('kc.add')}`;
|
||||
} else {
|
||||
(document.getElementById('kc-editor-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('kc-editor-name') as HTMLInputElement).value = '';
|
||||
if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0;
|
||||
(document.getElementById('kc-editor-fps') as HTMLInputElement).value = '10' as any;
|
||||
(document.getElementById('kc-editor-fps-value') as HTMLElement).textContent = '10';
|
||||
(document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value = 'average';
|
||||
if (_kcColorModeIconSelect) _kcColorModeIconSelect.setValue('average');
|
||||
(document.getElementById('kc-editor-smoothing') as HTMLInputElement).value = '0.3' as any;
|
||||
(document.getElementById('kc-editor-smoothing-value') as HTMLElement).textContent = '0.3';
|
||||
if (patTemplates.length > 0) patSelect.value = patTemplates[0].id;
|
||||
_populateKCBrightnessVsDropdown('');
|
||||
(document.getElementById('kc-editor-title') as HTMLElement).innerHTML = `${ICON_PALETTE} ${t('kc.add')}`;
|
||||
}
|
||||
|
||||
// Auto-name
|
||||
set_kcNameManuallyEdited(!!(targetId || cloneData));
|
||||
(document.getElementById('kc-editor-name') as HTMLInputElement).oninput = () => { set_kcNameManuallyEdited(true); };
|
||||
sourceSelect.onchange = () => _autoGenerateKCName();
|
||||
(document.getElementById('kc-editor-interpolation') as HTMLSelectElement).onchange = () => _autoGenerateKCName();
|
||||
patSelect.onchange = () => _autoGenerateKCName();
|
||||
if (!targetId && !cloneData) _autoGenerateKCName();
|
||||
|
||||
// Tags
|
||||
if (_kcTagsInput) _kcTagsInput.destroy();
|
||||
_kcTagsInput = new TagInput(document.getElementById('kc-tags-container'), {
|
||||
placeholder: t('tags.placeholder'),
|
||||
});
|
||||
_kcTagsInput.setValue(_editorTags);
|
||||
|
||||
kcEditorModal.snapshot();
|
||||
kcEditorModal.open();
|
||||
|
||||
(document.getElementById('kc-editor-error') as HTMLElement).style.display = 'none';
|
||||
setTimeout(() => desktopFocus(document.getElementById('kc-editor-name')), 100);
|
||||
} catch (error) {
|
||||
console.error('Failed to open KC editor:', error);
|
||||
showToast(t('kc_target.error.editor_open_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function isKCEditorDirty() {
|
||||
return kcEditorModal.isDirty();
|
||||
}
|
||||
|
||||
export async function closeKCEditorModal() {
|
||||
await kcEditorModal.close();
|
||||
set_kcNameManuallyEdited(false);
|
||||
}
|
||||
|
||||
export function forceCloseKCEditorModal() {
|
||||
if (_kcTagsInput) { _kcTagsInput.destroy(); _kcTagsInput = null; }
|
||||
kcEditorModal.forceClose();
|
||||
set_kcNameManuallyEdited(false);
|
||||
}
|
||||
|
||||
export async function saveKCEditor() {
|
||||
const targetId = (document.getElementById('kc-editor-id') as HTMLInputElement).value;
|
||||
const name = (document.getElementById('kc-editor-name') as HTMLInputElement).value.trim();
|
||||
const sourceId = (document.getElementById('kc-editor-source') as HTMLSelectElement).value;
|
||||
const fps = parseInt((document.getElementById('kc-editor-fps') as HTMLInputElement).value) || 10;
|
||||
const interpolation = (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value;
|
||||
const smoothing = parseFloat((document.getElementById('kc-editor-smoothing') as HTMLInputElement).value);
|
||||
const patternTemplateId = (document.getElementById('kc-editor-pattern-template') as HTMLSelectElement).value;
|
||||
const brightnessVsId = (document.getElementById('kc-editor-brightness-vs') as HTMLSelectElement).value;
|
||||
|
||||
if (!name) {
|
||||
kcEditorModal.showError(t('kc.error.required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!patternTemplateId) {
|
||||
kcEditorModal.showError(t('kc.error.no_pattern'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
name,
|
||||
picture_source_id: sourceId,
|
||||
tags: _kcTagsInput ? _kcTagsInput.getValue() : [],
|
||||
key_colors_settings: {
|
||||
fps,
|
||||
interpolation_mode: interpolation,
|
||||
smoothing,
|
||||
pattern_template_id: patternTemplateId,
|
||||
brightness_value_source_id: brightnessVsId,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (targetId) {
|
||||
response = await fetchWithAuth(`/output-targets/${targetId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
payload.target_type = 'key_colors';
|
||||
response = await fetchWithAuth('/output-targets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || 'Failed to save');
|
||||
}
|
||||
|
||||
showToast(targetId ? t('kc.updated') : t('kc.created'), 'success');
|
||||
outputTargetsCache.invalidate();
|
||||
kcEditorModal.forceClose();
|
||||
// Use window.* to avoid circular import with targets.js
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Error saving KC target:', error);
|
||||
kcEditorModal.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneKCTarget(targetId: any) {
|
||||
try {
|
||||
const targets = await outputTargetsCache.fetch();
|
||||
const target = targets.find((t: any) => t.id === targetId);
|
||||
if (!target) throw new Error('Target not found');
|
||||
showKCEditor(null, target);
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('kc_target.error.clone_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteKCTarget(targetId: any) {
|
||||
const confirmed = await showConfirm(t('kc.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
disconnectKCWebSocket(targetId);
|
||||
const response = await fetchWithAuth(`/output-targets/${targetId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('kc.deleted'), 'success');
|
||||
outputTargetsCache.invalidate();
|
||||
// Use window.* to avoid circular import with targets.js
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(error.detail || t('kc_target.error.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('kc_target.error.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== KC BRIGHTNESS =====
|
||||
|
||||
export function updateKCBrightnessLabel(targetId: any, value: any) {
|
||||
const slider = document.querySelector(`[data-kc-brightness="${CSS.escape(targetId)}"]`) as HTMLElement;
|
||||
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
|
||||
}
|
||||
|
||||
export async function saveKCBrightness(targetId: any, value: any) {
|
||||
const brightness = parseInt(value) / 255;
|
||||
try {
|
||||
await fetch(`${API_BASE}/output-targets/${targetId}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ key_colors_settings: { brightness } }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to save KC brightness:', err);
|
||||
showToast(t('kc.error.brightness') || 'Failed to save brightness', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== KEY COLORS WEBSOCKET =====
|
||||
|
||||
export function connectKCWebSocket(targetId: any) {
|
||||
// Disconnect existing connection if any
|
||||
disconnectKCWebSocket(targetId);
|
||||
|
||||
const key = localStorage.getItem('wled_api_key');
|
||||
if (!key) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/output-targets/${targetId}/ws?token=${encodeURIComponent(key)}`;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
updateKCColorSwatches(targetId, data.colors || {});
|
||||
} catch (e) {
|
||||
console.error('Failed to parse KC WebSocket message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
delete kcWebSockets[targetId];
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error(`KC WebSocket error for ${targetId}:`, error);
|
||||
};
|
||||
|
||||
kcWebSockets[targetId] = ws;
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect KC WebSocket for ${targetId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectKCWebSocket(targetId: any) {
|
||||
const ws = kcWebSockets[targetId];
|
||||
if (ws) {
|
||||
ws.close();
|
||||
delete kcWebSockets[targetId];
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectAllKCWebSockets() {
|
||||
Object.keys(kcWebSockets).forEach(targetId => disconnectKCWebSocket(targetId));
|
||||
}
|
||||
|
||||
export function updateKCColorSwatches(targetId: any, colors: any) {
|
||||
const container = document.getElementById(`kc-swatches-${targetId}`);
|
||||
if (!container) return;
|
||||
|
||||
const entries = Object.entries(colors);
|
||||
if (entries.length === 0) {
|
||||
container.innerHTML = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = entries.map(([name, color]: [string, any]) => {
|
||||
const hex = color.hex || `#${(color.r || 0).toString(16).padStart(2, '0')}${(color.g || 0).toString(16).padStart(2, '0')}${(color.b || 0).toString(16).padStart(2, '0')}`;
|
||||
return `
|
||||
<div class="kc-swatch">
|
||||
<div class="kc-swatch-color" style="background-color: ${hex}" title="${hex}"></div>
|
||||
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -18,7 +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 { createHALightTargetCard, initHALightTargetDelegation, patchHALightTargetMetrics } from './ha-light-targets.ts';
|
||||
import { createHALightTargetCard, initHALightTargetDelegation, patchHALightTargetMetrics, connectHALightWS, disconnectHALightWS } from './ha-light-targets.ts';
|
||||
import {
|
||||
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
|
||||
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
|
||||
@@ -616,21 +616,12 @@ export async function loadTargetsTab() {
|
||||
|
||||
const devicesWithState = devices.map(d => ({ ...d, state: allDeviceStates[d.id] || {} }));
|
||||
|
||||
// Enrich targets with state/metrics; fetch colors only for running KC targets
|
||||
const targetsWithState = await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
const state = allTargetStates[target.id] || {};
|
||||
const metrics = allTargetMetrics[target.id] || {};
|
||||
let latestColors = null;
|
||||
if (target.target_type === 'key_colors' && state.processing) {
|
||||
try {
|
||||
const colorsResp = await fetch(`${API_BASE}/output-targets/${target.id}/colors`, { headers: getHeaders() });
|
||||
if (colorsResp.ok) latestColors = await colorsResp.json();
|
||||
} catch {}
|
||||
}
|
||||
return { ...target, state, metrics, latestColors };
|
||||
})
|
||||
);
|
||||
// Enrich targets with state/metrics
|
||||
const targetsWithState = targets.map((target) => {
|
||||
const state = allTargetStates[target.id] || {};
|
||||
const metrics = allTargetMetrics[target.id] || {};
|
||||
return { ...target, state, metrics };
|
||||
});
|
||||
|
||||
// Build device map for target name resolution
|
||||
const deviceMap = {};
|
||||
@@ -769,6 +760,15 @@ export async function loadTargetsTab() {
|
||||
if (!processingLedIds.has(id)) disconnectLedPreviewWS(id);
|
||||
});
|
||||
|
||||
// Manage HA light color preview WebSockets
|
||||
haLightTargets.forEach(target => {
|
||||
if (target.state && target.state.processing) {
|
||||
connectHALightWS(target.id);
|
||||
} else {
|
||||
disconnectHALightWS(target.id);
|
||||
}
|
||||
});
|
||||
|
||||
// FPS charts: only destroy charts for replaced/removed cards (or all on first render)
|
||||
if (changedTargetIds) {
|
||||
// Incremental: destroy only charts whose cards were replaced or removed
|
||||
|
||||
+1
-15
@@ -157,19 +157,6 @@ interface Window {
|
||||
closeTestAudioTemplateModal: (...args: any[]) => any;
|
||||
startAudioTemplateTest: (...args: any[]) => any;
|
||||
|
||||
// ─── KC Targets ───
|
||||
createKCTargetCard: (...args: any[]) => any;
|
||||
testKCTarget: (...args: any[]) => any;
|
||||
showKCEditor: (...args: any[]) => any;
|
||||
closeKCEditorModal: (...args: any[]) => any;
|
||||
forceCloseKCEditorModal: (...args: any[]) => any;
|
||||
saveKCEditor: (...args: any[]) => any;
|
||||
deleteKCTarget: (...args: any[]) => any;
|
||||
disconnectAllKCWebSockets: (...args: any[]) => any;
|
||||
updateKCBrightnessLabel: (...args: any[]) => any;
|
||||
saveKCBrightness: (...args: any[]) => any;
|
||||
cloneKCTarget: (...args: any[]) => any;
|
||||
|
||||
// ─── Pattern Templates ───
|
||||
createPatternTemplateCard: (...args: any[]) => any;
|
||||
showPatternTemplateEditor: (...args: any[]) => any;
|
||||
@@ -226,8 +213,7 @@ interface Window {
|
||||
startTargetProcessing: (...args: any[]) => any;
|
||||
stopTargetProcessing: (...args: any[]) => any;
|
||||
stopAllLedTargets: (...args: any[]) => any;
|
||||
stopAllKCTargets: (...args: any[]) => any;
|
||||
startTargetOverlay: (...args: any[]) => any;
|
||||
startTargetOverlay: (...args: any[]) => any;
|
||||
stopTargetOverlay: (...args: any[]) => any;
|
||||
deleteTarget: (...args: any[]) => any;
|
||||
cloneTarget: (...args: any[]) => any;
|
||||
|
||||
@@ -45,16 +45,7 @@ export interface Device {
|
||||
|
||||
// ── Output Target ─────────────────────────────────────────────
|
||||
|
||||
export type TargetType = 'led' | 'key_colors';
|
||||
|
||||
export interface KeyColorsSettings {
|
||||
fps: number;
|
||||
interpolation_mode: string;
|
||||
smoothing: number;
|
||||
pattern_template_id: string;
|
||||
brightness: number;
|
||||
brightness_value_source_id: string;
|
||||
}
|
||||
export type TargetType = 'led' | 'ha_light';
|
||||
|
||||
export interface OutputTarget {
|
||||
id: string;
|
||||
@@ -76,9 +67,19 @@ export interface OutputTarget {
|
||||
adaptive_fps?: boolean;
|
||||
protocol?: string;
|
||||
|
||||
// Key Colors target fields
|
||||
picture_source_id?: string;
|
||||
key_colors_settings?: KeyColorsSettings;
|
||||
// HA light target fields
|
||||
ha_source_id?: string;
|
||||
ha_light_mappings?: HALightMapping[];
|
||||
update_rate?: number;
|
||||
ha_transition?: number;
|
||||
color_tolerance?: number;
|
||||
}
|
||||
|
||||
export interface HALightMapping {
|
||||
entity_id: string;
|
||||
led_start: number;
|
||||
led_end: number;
|
||||
brightness_scale: number;
|
||||
}
|
||||
|
||||
// ── Color Strip Source ────────────────────────────────────────
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user