Rework API input CSS: segments, remove led_count, HAOS light, test preview
API Input CSS rework:
- Remove led_count field from ApiInputColorStripSource (always auto-sizes)
- Add segment-based payload: solid, per_pixel, gradient modes
- Segments applied in order (last wins on overlap), auto-grow buffer
- Backward compatible: legacy {"colors": [...]} still works
- Pydantic validation: mode-specific field requirements
Test preview:
- Enable test preview button on api_input cards
- Hide LED/FPS controls for api_input (sender controls those)
- Show input source selector for all CSS tests (preselected)
- FPS sparkline chart using shared createFpsSparkline (same as target cards)
- Server only sends frames when push_generation changes (no idle frames)
HAOS integration:
- New light.py: ApiInputLight entity per api_input source (RGB + brightness)
- turn_on pushes solid segment, turn_off pushes fallback color
- Register wled_screen_controller.set_leds service for arbitrary segments
- New services.yaml with field definitions
- Coordinator: push_colors() and push_segments() methods
- Platform.LIGHT added to platforms list
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -269,6 +269,8 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* FPS chart for api_input test preview — reuses .target-fps-row from cards.css */
|
||||
|
||||
/* Composite layers preview */
|
||||
.css-test-layers {
|
||||
display: flex;
|
||||
@@ -346,7 +348,107 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Log viewer ─────────────────────────────────────────────── */
|
||||
/* ── Settings modal tabs ───────────────────────────────────── */
|
||||
|
||||
.settings-tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
padding: 0 1.25rem;
|
||||
}
|
||||
|
||||
.settings-tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: color 0.2s ease, border-color 0.25s ease;
|
||||
}
|
||||
|
||||
.settings-tab-btn:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.settings-tab-btn.active {
|
||||
color: var(--primary-text-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-panel.active {
|
||||
display: block;
|
||||
animation: tabFadeIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* ── Log viewer overlay (full-screen) ──────────────────────── */
|
||||
|
||||
.log-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-color, #111);
|
||||
padding: 12px 16px;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.log-overlay-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
z-index: 1;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.log-overlay-close:hover {
|
||||
color: var(--text-color);
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.log-overlay-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 10px;
|
||||
padding-right: 36px; /* space for corner close btn */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-overlay-toolbar h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.log-overlay .log-viewer-output {
|
||||
flex: 1;
|
||||
max-height: none;
|
||||
border-radius: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Log viewer base ───────────────────────────────────────── */
|
||||
|
||||
.log-viewer-output {
|
||||
background: #0d0d0d;
|
||||
|
||||
@@ -182,12 +182,14 @@ import { switchTab, initTabs, startAutoRefresh, handlePopState } from './feature
|
||||
import { navigateToCard } from './core/navigation.js';
|
||||
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js';
|
||||
import {
|
||||
openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected,
|
||||
openSettingsModal, closeSettingsModal, switchSettingsTab,
|
||||
downloadBackup, handleRestoreFileSelected,
|
||||
saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
|
||||
restartServer, saveMqttSettings,
|
||||
loadApiKeysList,
|
||||
downloadPartialExport, handlePartialImportFileSelected,
|
||||
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
|
||||
openLogOverlay, closeLogOverlay,
|
||||
loadLogLevel, setLogLevel,
|
||||
} from './features/settings.js';
|
||||
|
||||
@@ -522,9 +524,10 @@ Object.assign(window, {
|
||||
openCommandPalette,
|
||||
closeCommandPalette,
|
||||
|
||||
// settings (backup / restore / auto-backup / MQTT / partial export-import / api keys / log level)
|
||||
// settings (tabs / backup / restore / auto-backup / MQTT / partial export-import / api keys / log level)
|
||||
openSettingsModal,
|
||||
closeSettingsModal,
|
||||
switchSettingsTab,
|
||||
downloadBackup,
|
||||
handleRestoreFileSelected,
|
||||
saveAutoBackupSettings,
|
||||
@@ -540,6 +543,8 @@ Object.assign(window, {
|
||||
disconnectLogViewer,
|
||||
clearLogViewer,
|
||||
applyLogFilter,
|
||||
openLogOverlay,
|
||||
closeLogOverlay,
|
||||
loadLogLevel,
|
||||
setLogLevel,
|
||||
});
|
||||
@@ -569,8 +574,11 @@ document.addEventListener('keydown', (e) => {
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
// Close in order: overlay lightboxes first, then modals via stack
|
||||
if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
|
||||
// Close in order: log overlay > overlay lightboxes > modals via stack
|
||||
const logOverlay = document.getElementById('log-overlay');
|
||||
if (logOverlay && logOverlay.style.display !== 'none') {
|
||||
closeLogOverlay();
|
||||
} else if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
|
||||
closeDisplayPicker();
|
||||
} else if (document.getElementById('image-lightbox').classList.contains('active')) {
|
||||
closeLightbox();
|
||||
|
||||
@@ -30,11 +30,51 @@ const ENTITY_CACHE_MAP = {
|
||||
pattern_template: patternTemplatesCache,
|
||||
};
|
||||
|
||||
/** Maps entity_type to the window load function that refreshes its UI. */
|
||||
const ENTITY_LOADER_MAP = {
|
||||
device: 'loadTargetsTab',
|
||||
output_target: 'loadTargetsTab',
|
||||
color_strip_source: 'loadTargetsTab',
|
||||
pattern_template: 'loadTargetsTab',
|
||||
picture_source: 'loadPictureSources',
|
||||
audio_source: 'loadPictureSources',
|
||||
value_source: 'loadPictureSources',
|
||||
sync_clock: 'loadPictureSources',
|
||||
capture_template: 'loadPictureSources',
|
||||
audio_template: 'loadPictureSources',
|
||||
pp_template: 'loadPictureSources',
|
||||
automation: 'loadAutomations',
|
||||
scene_preset: 'loadAutomations',
|
||||
};
|
||||
|
||||
/** Debounce timers per loader function name — coalesces rapid WS events and
|
||||
* avoids a redundant re-render when the local save handler already triggered one. */
|
||||
const _loaderTimers = {};
|
||||
const _LOADER_DEBOUNCE_MS = 600;
|
||||
|
||||
function _invalidateAndReload(entityType) {
|
||||
const cache = ENTITY_CACHE_MAP[entityType];
|
||||
if (cache) {
|
||||
cache.fetch({ force: true });
|
||||
}
|
||||
if (!cache) return;
|
||||
|
||||
const oldData = cache.data;
|
||||
cache.fetch({ force: true }).then((newData) => {
|
||||
// Skip UI refresh if the data didn't actually change —
|
||||
// the local save handler already refreshed the UI.
|
||||
if (oldData === newData) return;
|
||||
if (Array.isArray(oldData) && Array.isArray(newData) &&
|
||||
oldData.length === newData.length &&
|
||||
JSON.stringify(oldData) === JSON.stringify(newData)) return;
|
||||
|
||||
const loader = ENTITY_LOADER_MAP[entityType];
|
||||
if (loader) {
|
||||
clearTimeout(_loaderTimers[loader]);
|
||||
_loaderTimers[loader] = setTimeout(() => {
|
||||
delete _loaderTimers[loader];
|
||||
if (typeof window[loader] === 'function') window[loader]();
|
||||
}, _LOADER_DEBOUNCE_MS);
|
||||
}
|
||||
});
|
||||
|
||||
document.dispatchEvent(new CustomEvent('entity:reload', {
|
||||
detail: { entity_type: entityType },
|
||||
}));
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* This module manages the editor modal and API operations.
|
||||
*/
|
||||
|
||||
import { _cachedAudioSources, _cachedAudioTemplates, apiKey } from '../core/state.js';
|
||||
import { _cachedAudioSources, _cachedAudioTemplates, apiKey, audioSourcesCache } from '../core/state.js';
|
||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js';
|
||||
@@ -155,6 +155,7 @@ export async function saveAudioSource() {
|
||||
}
|
||||
showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success');
|
||||
audioSourceModal.forceClose();
|
||||
audioSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
@@ -205,6 +206,7 @@ export async function deleteAudioSource(sourceId) {
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('audio_source.deleted'), 'success');
|
||||
audioSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
|
||||
@@ -706,6 +706,7 @@ export async function saveAutomationEditor() {
|
||||
|
||||
automationModal.forceClose();
|
||||
showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success');
|
||||
automationsCacheObj.invalidate();
|
||||
loadAutomations();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
@@ -720,6 +721,7 @@ export async function toggleAutomationEnabled(automationId, enable) {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Failed to ${action} automation`);
|
||||
automationsCacheObj.invalidate();
|
||||
loadAutomations();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
@@ -767,6 +769,7 @@ export async function deleteAutomation(automationId, automationName) {
|
||||
});
|
||||
if (!resp.ok) throw new Error('Failed to delete automation');
|
||||
showToast(t('automations.deleted'), 'success');
|
||||
automationsCacheObj.invalidate();
|
||||
loadAutomations();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { createFpsSparkline } from '../core/chart-utils.js';
|
||||
import { _cachedSyncClocks, _cachedValueSources, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
|
||||
@@ -198,8 +199,8 @@ export function onCSSTypeChange() {
|
||||
}
|
||||
_syncAnimationSpeedState();
|
||||
|
||||
// LED count — only shown for picture, picture_advanced, api_input
|
||||
const hasLedCount = ['picture', 'picture_advanced', 'api_input'];
|
||||
// LED count — only shown for picture, picture_advanced
|
||||
const hasLedCount = ['picture', 'picture_advanced'];
|
||||
document.getElementById('css-editor-led-count-group').style.display =
|
||||
hasLedCount.includes(type) ? '' : 'none';
|
||||
|
||||
@@ -1656,9 +1657,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
const notifHistoryBtn = isNotification
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
|
||||
: '';
|
||||
const testPreviewBtn = !isApiInput
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`
|
||||
: '';
|
||||
const testPreviewBtn = `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`;
|
||||
|
||||
return wrapCard({
|
||||
dataAttr: 'data-css-id',
|
||||
@@ -2259,6 +2258,7 @@ export async function saveCSSEditor() {
|
||||
showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
cssEditorModal.forceClose();
|
||||
if (window.loadPictureSources) window.loadPictureSources();
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
@@ -2335,6 +2335,7 @@ export async function deleteColorStrip(cssId) {
|
||||
if (response.ok) {
|
||||
showToast(t('color_strip.deleted'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
if (window.loadPictureSources) window.loadPictureSources();
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} else {
|
||||
const err = await response.json();
|
||||
@@ -2476,6 +2477,11 @@ let _cssTestGeneration = 0; // bumped on each connect to ignore stale WS messa
|
||||
let _cssTestNotificationIds = []; // notification source IDs to fire (self or composite layers)
|
||||
let _cssTestCSPTMode = false; // true when testing a CSPT template
|
||||
let _cssTestCSPTId = null; // CSPT template ID when in CSPT mode
|
||||
let _cssTestIsApiInput = false;
|
||||
let _cssTestFpsTimestamps = []; // raw timestamps for current-second FPS calculation
|
||||
let _cssTestFpsActualHistory = []; // rolling FPS samples for sparkline
|
||||
let _cssTestFpsChart = null;
|
||||
const _CSS_TEST_FPS_MAX_SAMPLES = 30;
|
||||
let _csptTestInputEntitySelect = null;
|
||||
|
||||
function _getCssTestLedCount() {
|
||||
@@ -2488,12 +2494,32 @@ function _getCssTestFps() {
|
||||
return (stored >= 1 && stored <= 60) ? stored : 20;
|
||||
}
|
||||
|
||||
function _populateCssTestSourceSelector(preselectId) {
|
||||
const sources = colorStripSourcesCache.data || [];
|
||||
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
|
||||
const sel = document.getElementById('css-test-cspt-input-select');
|
||||
sel.innerHTML = nonProcessed.map(s =>
|
||||
`<option value="${s.id}"${s.id === preselectId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
if (_csptTestInputEntitySelect) _csptTestInputEntitySelect.destroy();
|
||||
_csptTestInputEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => (colorStripSourcesCache.data || [])
|
||||
.filter(s => s.source_type !== 'processed')
|
||||
.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
}
|
||||
|
||||
export function testColorStrip(sourceId) {
|
||||
_cssTestCSPTMode = false;
|
||||
_cssTestCSPTId = null;
|
||||
// Hide CSPT input selector
|
||||
const csptGroup = document.getElementById('css-test-cspt-input-group');
|
||||
if (csptGroup) csptGroup.style.display = 'none';
|
||||
// Detect api_input type
|
||||
const sources = colorStripSourcesCache.data || [];
|
||||
const src = sources.find(s => s.id === sourceId);
|
||||
_cssTestIsApiInput = src?.source_type === 'api_input';
|
||||
// Populate input source selector with current source preselected
|
||||
_populateCssTestSourceSelector(sourceId);
|
||||
_openTestModal(sourceId);
|
||||
}
|
||||
|
||||
@@ -2503,25 +2529,9 @@ export async function testCSPT(templateId) {
|
||||
|
||||
// Populate input source selector
|
||||
await colorStripSourcesCache.fetch();
|
||||
const sources = colorStripSourcesCache.data || [];
|
||||
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
|
||||
const sel = document.getElementById('css-test-cspt-input-select');
|
||||
sel.innerHTML = nonProcessed.map(s =>
|
||||
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
// EntitySelect for input source picker
|
||||
if (_csptTestInputEntitySelect) _csptTestInputEntitySelect.destroy();
|
||||
_csptTestInputEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => (colorStripSourcesCache.data || [])
|
||||
.filter(s => s.source_type !== 'processed')
|
||||
.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
// Show CSPT input selector
|
||||
const csptGroup = document.getElementById('css-test-cspt-input-group');
|
||||
if (csptGroup) csptGroup.style.display = '';
|
||||
_populateCssTestSourceSelector(null);
|
||||
|
||||
const sel = document.getElementById('css-test-cspt-input-select');
|
||||
const inputId = sel.value;
|
||||
if (!inputId) {
|
||||
showToast(t('color_strip.processed.error.no_input'), 'error');
|
||||
@@ -2550,23 +2560,42 @@ function _openTestModal(sourceId) {
|
||||
document.getElementById('css-test-rect-view').style.display = 'none';
|
||||
document.getElementById('css-test-layers-view').style.display = 'none';
|
||||
document.getElementById('css-test-led-group').style.display = '';
|
||||
// Input source selector: shown for both CSS test and CSPT test, hidden for api_input
|
||||
const csptGroup = document.getElementById('css-test-cspt-input-group');
|
||||
if (csptGroup) csptGroup.style.display = _cssTestIsApiInput ? 'none' : '';
|
||||
const layersContainer = document.getElementById('css-test-layers');
|
||||
if (layersContainer) layersContainer.innerHTML = '';
|
||||
document.getElementById('css-test-status').style.display = '';
|
||||
document.getElementById('css-test-status').textContent = t('color_strip.test.connecting');
|
||||
|
||||
// Restore LED count + FPS + Enter key handlers
|
||||
const ledCount = _getCssTestLedCount();
|
||||
const ledInput = document.getElementById('css-test-led-input');
|
||||
ledInput.value = ledCount;
|
||||
ledInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
|
||||
// Reset FPS tracking
|
||||
_cssTestFpsHistory = [];
|
||||
|
||||
const fpsVal = _getCssTestFps();
|
||||
const fpsInput = document.getElementById('css-test-fps-input');
|
||||
fpsInput.value = fpsVal;
|
||||
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
|
||||
// For api_input: hide LED/FPS controls, show FPS chart
|
||||
const ledControlGroup = document.getElementById('css-test-led-fps-group');
|
||||
const fpsChartGroup = document.getElementById('css-test-fps-chart-group');
|
||||
if (_cssTestIsApiInput) {
|
||||
if (ledControlGroup) ledControlGroup.style.display = 'none';
|
||||
if (fpsChartGroup) fpsChartGroup.style.display = '';
|
||||
_cssTestStartFpsSampling();
|
||||
// Use large LED count (buffer auto-sizes) and high poll FPS
|
||||
_cssTestConnect(sourceId, 1000, 60);
|
||||
} else {
|
||||
if (ledControlGroup) ledControlGroup.style.display = '';
|
||||
if (fpsChartGroup) fpsChartGroup.style.display = 'none';
|
||||
// Restore LED count + FPS + Enter key handlers
|
||||
const ledCount = _getCssTestLedCount();
|
||||
const ledInput = document.getElementById('css-test-led-input');
|
||||
ledInput.value = ledCount;
|
||||
ledInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
|
||||
|
||||
_cssTestConnect(sourceId, ledCount, fpsVal);
|
||||
const fpsVal = _getCssTestFps();
|
||||
const fpsInput = document.getElementById('css-test-fps-input');
|
||||
fpsInput.value = fpsVal;
|
||||
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
|
||||
|
||||
_cssTestConnect(sourceId, ledCount, fpsVal);
|
||||
}
|
||||
}
|
||||
|
||||
function _cssTestConnect(sourceId, ledCount, fps) {
|
||||
@@ -2714,6 +2743,11 @@ function _cssTestConnect(sourceId, ledCount, fps) {
|
||||
// Standard format: raw RGB
|
||||
_cssTestLatestRgb = raw;
|
||||
}
|
||||
|
||||
// Track FPS for api_input sources
|
||||
if (_cssTestIsApiInput) {
|
||||
_cssTestFpsTimestamps.push(performance.now());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2805,12 +2839,14 @@ export function applyCssTestSettings() {
|
||||
_cssTestMeta = null;
|
||||
_cssTestLayerData = null;
|
||||
|
||||
// In CSPT mode, read selected input source
|
||||
if (_cssTestCSPTMode) {
|
||||
const inputSel = document.getElementById('css-test-cspt-input-select');
|
||||
if (inputSel && inputSel.value) {
|
||||
_cssTestSourceId = inputSel.value;
|
||||
}
|
||||
// Read selected input source from selector (both CSS and CSPT modes)
|
||||
const inputSel = document.getElementById('css-test-cspt-input-select');
|
||||
if (inputSel && inputSel.value) {
|
||||
_cssTestSourceId = inputSel.value;
|
||||
// Re-detect api_input when source changes
|
||||
const sources = colorStripSourcesCache.data || [];
|
||||
const src = sources.find(s => s.id === _cssTestSourceId);
|
||||
_cssTestIsApiInput = src?.source_type === 'api_input';
|
||||
}
|
||||
|
||||
// Reconnect (generation counter ignores stale frames from old WS)
|
||||
@@ -3162,6 +3198,60 @@ export function fireCssTestNotificationLayer(sourceId) {
|
||||
testNotification(sourceId);
|
||||
}
|
||||
|
||||
let _cssTestFpsSampleInterval = null;
|
||||
|
||||
function _cssTestStartFpsSampling() {
|
||||
_cssTestStopFpsSampling();
|
||||
_cssTestFpsTimestamps = [];
|
||||
_cssTestFpsActualHistory = [];
|
||||
if (_cssTestFpsChart) { _cssTestFpsChart.destroy(); _cssTestFpsChart = null; }
|
||||
|
||||
// Sample FPS every 1 second
|
||||
_cssTestFpsSampleInterval = setInterval(() => {
|
||||
const now = performance.now();
|
||||
// Count frames in the last 1 second
|
||||
const cutoff = now - 1000;
|
||||
_cssTestFpsTimestamps = _cssTestFpsTimestamps.filter(t => t >= cutoff);
|
||||
const fps = _cssTestFpsTimestamps.length;
|
||||
|
||||
_cssTestFpsActualHistory.push(fps);
|
||||
if (_cssTestFpsActualHistory.length > _CSS_TEST_FPS_MAX_SAMPLES)
|
||||
_cssTestFpsActualHistory.shift();
|
||||
|
||||
// Update numeric display (match target card format)
|
||||
const valueEl = document.getElementById('css-test-fps-value');
|
||||
if (valueEl) valueEl.textContent = fps;
|
||||
const avgEl = document.getElementById('css-test-fps-avg');
|
||||
if (avgEl && _cssTestFpsActualHistory.length > 1) {
|
||||
const avg = _cssTestFpsActualHistory.reduce((a, b) => a + b, 0) / _cssTestFpsActualHistory.length;
|
||||
avgEl.textContent = `avg ${avg.toFixed(1)}`;
|
||||
}
|
||||
|
||||
// Create or update chart
|
||||
if (!_cssTestFpsChart) {
|
||||
_cssTestFpsChart = createFpsSparkline(
|
||||
'css-test-fps-chart',
|
||||
_cssTestFpsActualHistory,
|
||||
[], // no "current" dataset, just actual
|
||||
60, // y-axis max
|
||||
);
|
||||
}
|
||||
if (_cssTestFpsChart) {
|
||||
const ds = _cssTestFpsChart.data.datasets[0].data;
|
||||
ds.length = 0;
|
||||
ds.push(..._cssTestFpsActualHistory);
|
||||
while (_cssTestFpsChart.data.labels.length < ds.length) _cssTestFpsChart.data.labels.push('');
|
||||
_cssTestFpsChart.data.labels.length = ds.length;
|
||||
_cssTestFpsChart.update('none');
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function _cssTestStopFpsSampling() {
|
||||
if (_cssTestFpsSampleInterval) { clearInterval(_cssTestFpsSampleInterval); _cssTestFpsSampleInterval = null; }
|
||||
if (_cssTestFpsChart) { _cssTestFpsChart.destroy(); _cssTestFpsChart = null; }
|
||||
}
|
||||
|
||||
export function closeTestCssSourceModal() {
|
||||
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
|
||||
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
|
||||
@@ -3171,6 +3261,10 @@ export function closeTestCssSourceModal() {
|
||||
_cssTestIsComposite = false;
|
||||
_cssTestLayerData = null;
|
||||
_cssTestNotificationIds = [];
|
||||
_cssTestIsApiInput = false;
|
||||
_cssTestStopFpsSampling();
|
||||
_cssTestFpsTimestamps = [];
|
||||
_cssTestFpsActualHistory = [];
|
||||
// Revoke blob URL for frame preview
|
||||
const screen = document.getElementById('css-test-rect-screen');
|
||||
if (screen && screen._blobUrl) { URL.revokeObjectURL(screen._blobUrl); screen._blobUrl = null; screen.style.backgroundImage = ''; }
|
||||
|
||||
@@ -243,6 +243,7 @@ export async function saveScenePreset() {
|
||||
|
||||
scenePresetModal.forceClose();
|
||||
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
@@ -348,6 +349,7 @@ export async function recaptureScenePreset(presetId) {
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast(t('scenes.recaptured'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} else {
|
||||
showToast(t('scenes.error.recapture_failed'), 'error');
|
||||
@@ -420,6 +422,7 @@ export async function deleteScenePreset(presetId) {
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast(t('scenes.deleted'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} else {
|
||||
showToast(t('scenes.error.delete_failed'), 'error');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Settings — backup / restore configuration.
|
||||
* Settings — tabbed modal (General / Backup / MQTT) + full-screen Log overlay.
|
||||
*/
|
||||
|
||||
import { apiKey } from '../core/state.js';
|
||||
@@ -10,6 +10,17 @@ import { t } from '../core/i18n.js';
|
||||
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
|
||||
// ─── Settings-modal tab switching ───────────────────────────
|
||||
|
||||
export function switchSettingsTab(tabId) {
|
||||
document.querySelectorAll('.settings-tab-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.settingsTab === tabId);
|
||||
});
|
||||
document.querySelectorAll('.settings-panel').forEach(panel => {
|
||||
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Log Viewer ────────────────────────────────────────────
|
||||
|
||||
/** @type {WebSocket|null} */
|
||||
@@ -114,9 +125,6 @@ export function clearLogViewer() {
|
||||
|
||||
/** Re-render the log output according to the current filter selection. */
|
||||
export function applyLogFilter() {
|
||||
// We don't buffer all raw lines in JS — just clear and note the filter
|
||||
// will apply to future lines. Existing lines that were already rendered
|
||||
// are re-evaluated by toggling their visibility.
|
||||
const output = document.getElementById('log-viewer-output');
|
||||
if (!output) return;
|
||||
const filter = _filterLevel();
|
||||
@@ -128,50 +136,84 @@ export function applyLogFilter() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Log Overlay (full-screen) ──────────────────────────────
|
||||
|
||||
let _logFilterIconSelect = null;
|
||||
|
||||
/** Build filter items lazily so t() has locale data loaded. */
|
||||
function _getLogFilterItems() {
|
||||
return [
|
||||
{ value: 'all', icon: '<span style="color:#9e9e9e;font-weight:700">*</span>', label: t('settings.logs.filter.all'), desc: t('settings.logs.filter.all_desc') },
|
||||
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: t('settings.logs.filter.info'), desc: t('settings.logs.filter.info_desc') },
|
||||
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: t('settings.logs.filter.warning'), desc: t('settings.logs.filter.warning_desc') },
|
||||
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: t('settings.logs.filter.error'), desc: t('settings.logs.filter.error_desc') },
|
||||
];
|
||||
}
|
||||
|
||||
export function openLogOverlay() {
|
||||
const overlay = document.getElementById('log-overlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = 'flex';
|
||||
|
||||
// Initialize log filter icon select (once)
|
||||
if (!_logFilterIconSelect) {
|
||||
const filterSel = document.getElementById('log-viewer-filter');
|
||||
if (filterSel) {
|
||||
_logFilterIconSelect = new IconSelect({
|
||||
target: filterSel,
|
||||
items: _getLogFilterItems(),
|
||||
columns: 2,
|
||||
onChange: () => applyLogFilter(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-connect when opening
|
||||
if (!_logWs || _logWs.readyState !== WebSocket.OPEN) {
|
||||
connectLogViewer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function closeLogOverlay() {
|
||||
const overlay = document.getElementById('log-overlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
disconnectLogViewer();
|
||||
}
|
||||
|
||||
// ─── Settings Modal ─────────────────────────────────────────
|
||||
|
||||
// Simple modal (no form / no dirty check needed)
|
||||
const settingsModal = new Modal('settings-modal');
|
||||
|
||||
let _logFilterIconSelect = null;
|
||||
let _logLevelIconSelect = null;
|
||||
|
||||
const _LOG_LEVEL_ITEMS = [
|
||||
{ value: 'DEBUG', icon: '<span style="color:#9e9e9e;font-weight:700">D</span>', label: 'DEBUG', desc: t('settings.log_level.desc.debug') },
|
||||
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: 'INFO', desc: t('settings.log_level.desc.info') },
|
||||
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: 'WARNING', desc: t('settings.log_level.desc.warning') },
|
||||
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: 'ERROR', desc: t('settings.log_level.desc.error') },
|
||||
{ value: 'CRITICAL', icon: '<span style="color:#ff1744;font-weight:700">!</span>', label: 'CRITICAL', desc: t('settings.log_level.desc.critical') },
|
||||
];
|
||||
|
||||
const _LOG_FILTER_ITEMS = [
|
||||
{ value: 'all', icon: '<span style="color:#9e9e9e;font-weight:700">*</span>', label: t('settings.logs.filter.all'), desc: t('settings.logs.filter.all_desc') },
|
||||
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: t('settings.logs.filter.info'), desc: t('settings.logs.filter.info_desc') },
|
||||
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: t('settings.logs.filter.warning'), desc: t('settings.logs.filter.warning_desc') },
|
||||
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: t('settings.logs.filter.error'), desc: t('settings.logs.filter.error_desc') },
|
||||
];
|
||||
/** Build log-level items lazily so t() has locale data loaded. */
|
||||
function _getLogLevelItems() {
|
||||
return [
|
||||
{ value: 'DEBUG', icon: '<span style="color:#9e9e9e;font-weight:700">D</span>', label: 'DEBUG', desc: t('settings.log_level.desc.debug') },
|
||||
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: 'INFO', desc: t('settings.log_level.desc.info') },
|
||||
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: 'WARNING', desc: t('settings.log_level.desc.warning') },
|
||||
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: 'ERROR', desc: t('settings.log_level.desc.error') },
|
||||
{ value: 'CRITICAL', icon: '<span style="color:#ff1744;font-weight:700">!</span>', label: 'CRITICAL', desc: t('settings.log_level.desc.critical') },
|
||||
];
|
||||
}
|
||||
|
||||
export function openSettingsModal() {
|
||||
document.getElementById('settings-error').style.display = 'none';
|
||||
|
||||
// Reset to first tab
|
||||
switchSettingsTab('general');
|
||||
|
||||
settingsModal.open();
|
||||
|
||||
// Initialize log filter icon select
|
||||
if (!_logFilterIconSelect) {
|
||||
const filterSel = document.getElementById('log-viewer-filter');
|
||||
if (filterSel) {
|
||||
_logFilterIconSelect = new IconSelect({
|
||||
target: filterSel,
|
||||
items: _LOG_FILTER_ITEMS,
|
||||
columns: 2,
|
||||
onChange: () => applyLogFilter(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Initialize log level icon select
|
||||
if (!_logLevelIconSelect) {
|
||||
const levelSel = document.getElementById('settings-log-level');
|
||||
if (levelSel) {
|
||||
_logLevelIconSelect = new IconSelect({
|
||||
target: levelSel,
|
||||
items: _LOG_LEVEL_ITEMS,
|
||||
items: _getLogLevelItems(),
|
||||
columns: 3,
|
||||
onChange: () => setLogLevel(),
|
||||
});
|
||||
@@ -186,7 +228,6 @@ export function openSettingsModal() {
|
||||
}
|
||||
|
||||
export function closeSettingsModal() {
|
||||
disconnectLogViewer();
|
||||
settingsModal.forceClose();
|
||||
}
|
||||
|
||||
|
||||
@@ -710,6 +710,7 @@ export async function saveTemplate() {
|
||||
|
||||
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
|
||||
templateModal.forceClose();
|
||||
captureTemplatesCache.invalidate();
|
||||
await loadCaptureTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error saving template:', error);
|
||||
@@ -729,6 +730,7 @@ export async function deleteTemplate(templateId) {
|
||||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||||
}
|
||||
showToast(t('templates.deleted'), 'success');
|
||||
captureTemplatesCache.invalidate();
|
||||
await loadCaptureTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error deleting template:', error);
|
||||
@@ -970,6 +972,7 @@ export async function saveAudioTemplate() {
|
||||
|
||||
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
|
||||
audioTemplateModal.forceClose();
|
||||
audioTemplatesCache.invalidate();
|
||||
await loadAudioTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error saving audio template:', error);
|
||||
@@ -989,6 +992,7 @@ export async function deleteAudioTemplate(templateId) {
|
||||
throw new Error(error.detail || error.message || 'Failed to delete audio template');
|
||||
}
|
||||
showToast(t('audio_template.deleted'), 'success');
|
||||
audioTemplatesCache.invalidate();
|
||||
await loadAudioTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error deleting audio template:', error);
|
||||
@@ -2089,6 +2093,7 @@ export async function saveStream() {
|
||||
|
||||
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
|
||||
streamModal.forceClose();
|
||||
streamsCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (error) {
|
||||
console.error('Error saving stream:', error);
|
||||
@@ -2108,6 +2113,7 @@ export async function deleteStream(streamId) {
|
||||
throw new Error(error.detail || error.message || 'Failed to delete stream');
|
||||
}
|
||||
showToast(t('streams.deleted'), 'success');
|
||||
streamsCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (error) {
|
||||
console.error('Error deleting stream:', error);
|
||||
@@ -2675,6 +2681,7 @@ export async function savePPTemplate() {
|
||||
|
||||
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
|
||||
ppTemplateModal.forceClose();
|
||||
ppTemplatesCache.invalidate();
|
||||
await loadPPTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error saving PP template:', error);
|
||||
@@ -2735,6 +2742,7 @@ export async function deletePPTemplate(templateId) {
|
||||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||||
}
|
||||
showToast(t('postprocessing.deleted'), 'success');
|
||||
ppTemplatesCache.invalidate();
|
||||
await loadPPTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error deleting PP template:', error);
|
||||
@@ -2888,6 +2896,7 @@ export async function saveCSPT() {
|
||||
|
||||
showToast(templateId ? t('css_processing.updated') : t('css_processing.created'), 'success');
|
||||
csptModal.forceClose();
|
||||
csptCache.invalidate();
|
||||
await loadCSPTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error saving CSPT:', error);
|
||||
@@ -2920,6 +2929,7 @@ export async function deleteCSPT(templateId) {
|
||||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||||
}
|
||||
showToast(t('css_processing.deleted'), 'success');
|
||||
csptCache.invalidate();
|
||||
await loadCSPTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error deleting CSPT:', error);
|
||||
|
||||
@@ -98,6 +98,7 @@ export async function saveSyncClock() {
|
||||
}
|
||||
showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success');
|
||||
syncClockModal.forceClose();
|
||||
syncClocksCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
@@ -143,6 +144,7 @@ export async function deleteSyncClock(clockId) {
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('sync_clock.deleted'), 'success');
|
||||
syncClocksCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* This module manages the editor modal and API operations.
|
||||
*/
|
||||
|
||||
import { _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey } from '../core/state.js';
|
||||
import { _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache } from '../core/state.js';
|
||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
@@ -486,6 +486,7 @@ export async function saveValueSource() {
|
||||
}
|
||||
showToast(t(id ? 'value_source.updated' : 'value_source.created'), 'success');
|
||||
valueSourceModal.forceClose();
|
||||
valueSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
@@ -536,6 +537,7 @@ export async function deleteValueSource(sourceId) {
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('value_source.deleted'), 'success');
|
||||
valueSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
|
||||
@@ -312,6 +312,10 @@
|
||||
"device.tip.webui": "Open the device's built-in web interface for advanced configuration",
|
||||
"device.tip.add": "Click here to add a new LED device",
|
||||
"settings.title": "Settings",
|
||||
"settings.tab.general": "General",
|
||||
"settings.tab.backup": "Backup",
|
||||
"settings.tab.mqtt": "MQTT",
|
||||
"settings.logs.open_viewer": "Open Log Viewer",
|
||||
"settings.general.title": "General Settings",
|
||||
"settings.capture.title": "Capture Settings",
|
||||
"settings.capture.saved": "Capture settings updated",
|
||||
@@ -1076,6 +1080,7 @@
|
||||
"color_strip.test.error": "Failed to connect to preview stream",
|
||||
"color_strip.test.led_count": "LEDs:",
|
||||
"color_strip.test.fps": "FPS:",
|
||||
"color_strip.test.receive_fps": "Receive FPS",
|
||||
"color_strip.test.apply": "Apply",
|
||||
"color_strip.test.composite": "Composite",
|
||||
"color_strip.preview.title": "Live Preview",
|
||||
|
||||
@@ -312,6 +312,10 @@
|
||||
"device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки",
|
||||
"device.tip.add": "Нажмите, чтобы добавить новое LED устройство",
|
||||
"settings.title": "Настройки",
|
||||
"settings.tab.general": "Основные",
|
||||
"settings.tab.backup": "Бэкап",
|
||||
"settings.tab.mqtt": "MQTT",
|
||||
"settings.logs.open_viewer": "Открыть логи",
|
||||
"settings.general.title": "Основные Настройки",
|
||||
"settings.capture.title": "Настройки Захвата",
|
||||
"settings.capture.saved": "Настройки захвата обновлены",
|
||||
@@ -1076,6 +1080,7 @@
|
||||
"color_strip.test.error": "Не удалось подключиться к потоку предпросмотра",
|
||||
"color_strip.test.led_count": "Кол-во LED:",
|
||||
"color_strip.test.fps": "FPS:",
|
||||
"color_strip.test.receive_fps": "Частота приёма",
|
||||
"color_strip.test.apply": "Применить",
|
||||
"color_strip.test.composite": "Композит",
|
||||
"color_strip.preview.title": "Предпросмотр",
|
||||
|
||||
@@ -312,6 +312,10 @@
|
||||
"device.tip.webui": "打开设备内置的 Web 界面进行高级配置",
|
||||
"device.tip.add": "点击此处添加新的 LED 设备",
|
||||
"settings.title": "设置",
|
||||
"settings.tab.general": "常规",
|
||||
"settings.tab.backup": "备份",
|
||||
"settings.tab.mqtt": "MQTT",
|
||||
"settings.logs.open_viewer": "打开日志查看器",
|
||||
"settings.general.title": "常规设置",
|
||||
"settings.capture.title": "采集设置",
|
||||
"settings.capture.saved": "采集设置已更新",
|
||||
@@ -1076,6 +1080,7 @@
|
||||
"color_strip.test.error": "无法连接到预览流",
|
||||
"color_strip.test.led_count": "LED数量:",
|
||||
"color_strip.test.fps": "FPS:",
|
||||
"color_strip.test.receive_fps": "接收帧率",
|
||||
"color_strip.test.apply": "应用",
|
||||
"color_strip.test.composite": "合成",
|
||||
"color_strip.preview.title": "实时预览",
|
||||
|
||||
Reference in New Issue
Block a user