Add profile conditions, scene presets, MQTT integration, and Scenes tab
Feature 1 — Profile Conditions: time-of-day, system idle (Win32 GetLastInputInfo), and display state (GUID_CONSOLE_DISPLAY_STATE) condition types for automatic profile activation. Feature 2 — Scene Presets: snapshot/restore system that captures target running states, device brightness, and profile enables. Server-side capture with 5-step activation order. Dedicated Scenes tab with CardSection-based card grid, command palette integration, and dashboard quick-activate section. Feature 3 — MQTT Integration: MQTTService singleton with aiomqtt, MQTTLEDClient device provider for pixel output, MQTT profile condition type with topic/payload matching, and frontend support for MQTT device type and condition editor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
336
server/src/wled_controller/static/js/features/scene-presets.js
Normal file
336
server/src/wled_controller/static/js/features/scene-presets.js
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Scene Presets — capture, activate, edit, delete system state snapshots.
|
||||
* Renders as a dedicated tab and also provides dashboard section rendering.
|
||||
*/
|
||||
|
||||
import { apiKey } from '../core/state.js';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { CardSection } from '../core/card-sections.js';
|
||||
import { updateTabBadge } from './tabs.js';
|
||||
import {
|
||||
ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_SETTINGS,
|
||||
} from '../core/icons.js';
|
||||
|
||||
let _presetsCache = [];
|
||||
let _editingId = null;
|
||||
let _scenesLoading = false;
|
||||
|
||||
class ScenePresetEditorModal extends Modal {
|
||||
constructor() { super('scene-preset-editor-modal'); }
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('scene-preset-editor-name').value,
|
||||
description: document.getElementById('scene-preset-editor-description').value,
|
||||
color: document.getElementById('scene-preset-editor-color').value,
|
||||
};
|
||||
}
|
||||
}
|
||||
const scenePresetModal = new ScenePresetEditorModal();
|
||||
|
||||
const csScenes = new CardSection('scenes', {
|
||||
titleKey: 'scenes.title',
|
||||
gridClass: 'devices-grid',
|
||||
addCardOnclick: "openScenePresetCapture()",
|
||||
keyAttr: 'data-scene-id',
|
||||
});
|
||||
|
||||
// Re-render scenes when language changes (only if tab is active)
|
||||
document.addEventListener('languageChanged', () => {
|
||||
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'scenes') loadScenes();
|
||||
});
|
||||
|
||||
// ===== Tab rendering =====
|
||||
|
||||
export async function loadScenes() {
|
||||
if (_scenesLoading) return;
|
||||
_scenesLoading = true;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/scene-presets');
|
||||
if (!resp.ok) { _scenesLoading = false; return; }
|
||||
const data = await resp.json();
|
||||
_presetsCache = data.presets || [];
|
||||
} catch {
|
||||
_scenesLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById('scenes-content');
|
||||
const items = csScenes.applySortOrder(_presetsCache.map(p => ({ key: p.id, html: _createSceneCard(p) })));
|
||||
|
||||
updateTabBadge('scenes', _presetsCache.length);
|
||||
|
||||
if (csScenes.isMounted()) {
|
||||
csScenes.reconcile(items);
|
||||
} else {
|
||||
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllSceneSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllSceneSections()" title="${t('section.collapse_all')}">⊟</button></span></div>`;
|
||||
container.innerHTML = toolbar + csScenes.render(items);
|
||||
csScenes.bind();
|
||||
}
|
||||
|
||||
_scenesLoading = false;
|
||||
}
|
||||
|
||||
export function expandAllSceneSections() {
|
||||
CardSection.expandAll([csScenes]);
|
||||
}
|
||||
|
||||
export function collapseAllSceneSections() {
|
||||
CardSection.collapseAll([csScenes]);
|
||||
}
|
||||
|
||||
function _createSceneCard(preset) {
|
||||
const targetCount = (preset.targets || []).length;
|
||||
const deviceCount = (preset.devices || []).length;
|
||||
const profileCount = (preset.profiles || []).length;
|
||||
const colorStyle = `border-left: 3px solid ${escapeHtml(preset.color || '#4fc3f7')}`;
|
||||
|
||||
const meta = [
|
||||
targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null,
|
||||
deviceCount > 0 ? `${ICON_SETTINGS} ${deviceCount} ${t('scenes.devices_count')}` : null,
|
||||
profileCount > 0 ? `${profileCount} ${t('scenes.profiles_count')}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : '';
|
||||
|
||||
return `<div class="card" data-scene-id="${preset.id}" style="${colorStyle}">
|
||||
<div class="card-top-actions">
|
||||
<button class="card-remove-btn" onclick="deleteScenePreset('${preset.id}', '${escapeHtml(preset.name)}')" title="${t('scenes.delete')}">✕</button>
|
||||
</div>
|
||||
<div class="card-header">
|
||||
<div class="card-title">${escapeHtml(preset.name)}</div>
|
||||
</div>
|
||||
${preset.description ? `<div class="card-subtitle"><span class="card-meta">${escapeHtml(preset.description)}</span></div>` : ''}
|
||||
<div class="stream-card-props">
|
||||
${meta.map(m => `<span class="stream-card-prop">${m}</span>`).join('')}
|
||||
${updated ? `<span class="stream-card-prop">${updated}</span>` : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-icon btn-secondary" onclick="editScenePreset('${preset.id}')" title="${t('scenes.edit')}">${ICON_EDIT}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="recaptureScenePreset('${preset.id}')" title="${t('scenes.recapture')}">${ICON_REFRESH}</button>
|
||||
<button class="btn btn-icon btn-success" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ===== Dashboard section (compact cards) =====
|
||||
|
||||
export async function loadScenePresets() {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/scene-presets');
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json();
|
||||
_presetsCache = data.presets || [];
|
||||
return _presetsCache;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function renderScenePresetsSection(presets) {
|
||||
if (!presets || presets.length === 0) return '';
|
||||
|
||||
const captureBtn = `<button class="btn btn-sm btn-primary" onclick="event.stopPropagation(); openScenePresetCapture()" title="${t('scenes.capture')}">${ICON_CAPTURE} ${t('scenes.capture')}</button>`;
|
||||
const cards = presets.map(p => _renderDashboardPresetCard(p)).join('');
|
||||
|
||||
return { headerExtra: captureBtn, content: `<div class="dashboard-autostart-grid">${cards}</div>` };
|
||||
}
|
||||
|
||||
function _renderDashboardPresetCard(preset) {
|
||||
const borderStyle = `border-left: 3px solid ${escapeHtml(preset.color)}`;
|
||||
const targetCount = (preset.targets || []).length;
|
||||
const deviceCount = (preset.devices || []).length;
|
||||
const profileCount = (preset.profiles || []).length;
|
||||
|
||||
const subtitle = [
|
||||
targetCount > 0 ? `${targetCount} ${t('scenes.targets_count')}` : null,
|
||||
deviceCount > 0 ? `${deviceCount} ${t('scenes.devices_count')}` : null,
|
||||
profileCount > 0 ? `${profileCount} ${t('scenes.profiles_count')}` : null,
|
||||
].filter(Boolean).join(' \u00b7 ');
|
||||
|
||||
return `<div class="dashboard-target dashboard-scene-preset" data-scene-id="${preset.id}" style="${borderStyle}">
|
||||
<div class="dashboard-target-info" onclick="activateScenePreset('${preset.id}')">
|
||||
<span class="dashboard-target-icon">${ICON_SCENE}</span>
|
||||
<div>
|
||||
<div class="dashboard-target-name">${escapeHtml(preset.name)}</div>
|
||||
${preset.description ? `<div class="dashboard-target-subtitle">${escapeHtml(preset.description)}</div>` : ''}
|
||||
<div class="dashboard-target-subtitle">${subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-target-actions">
|
||||
<button class="dashboard-action-btn start" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ===== Capture (create) =====
|
||||
|
||||
export function openScenePresetCapture() {
|
||||
_editingId = null;
|
||||
document.getElementById('scene-preset-editor-id').value = '';
|
||||
document.getElementById('scene-preset-editor-name').value = '';
|
||||
document.getElementById('scene-preset-editor-description').value = '';
|
||||
document.getElementById('scene-preset-editor-color').value = '#4fc3f7';
|
||||
document.getElementById('scene-preset-editor-error').style.display = 'none';
|
||||
|
||||
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
|
||||
if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.add'); titleEl.textContent = t('scenes.add'); }
|
||||
|
||||
scenePresetModal.open();
|
||||
scenePresetModal.snapshot();
|
||||
}
|
||||
|
||||
// ===== Edit metadata =====
|
||||
|
||||
export async function editScenePreset(presetId) {
|
||||
const preset = _presetsCache.find(p => p.id === presetId);
|
||||
if (!preset) return;
|
||||
|
||||
_editingId = presetId;
|
||||
document.getElementById('scene-preset-editor-id').value = presetId;
|
||||
document.getElementById('scene-preset-editor-name').value = preset.name;
|
||||
document.getElementById('scene-preset-editor-description').value = preset.description || '';
|
||||
document.getElementById('scene-preset-editor-color').value = preset.color || '#4fc3f7';
|
||||
document.getElementById('scene-preset-editor-error').style.display = 'none';
|
||||
|
||||
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
|
||||
if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.edit'); titleEl.textContent = t('scenes.edit'); }
|
||||
|
||||
scenePresetModal.open();
|
||||
scenePresetModal.snapshot();
|
||||
}
|
||||
|
||||
// ===== Save (create or update) =====
|
||||
|
||||
export async function saveScenePreset() {
|
||||
const name = document.getElementById('scene-preset-editor-name').value.trim();
|
||||
const description = document.getElementById('scene-preset-editor-description').value.trim();
|
||||
const color = document.getElementById('scene-preset-editor-color').value;
|
||||
const errorEl = document.getElementById('scene-preset-editor-error');
|
||||
|
||||
if (!name) {
|
||||
errorEl.textContent = t('scenes.error.name_required');
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let resp;
|
||||
if (_editingId) {
|
||||
resp = await fetchWithAuth(`/scene-presets/${_editingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description, color }),
|
||||
});
|
||||
} else {
|
||||
resp = await fetchWithAuth('/scene-presets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description, color }),
|
||||
});
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
errorEl.textContent = err.detail || t('scenes.error.save_failed');
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
scenePresetModal.forceClose();
|
||||
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
|
||||
_reloadScenesTab();
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
errorEl.textContent = t('scenes.error.save_failed');
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeScenePresetEditor() {
|
||||
await scenePresetModal.close();
|
||||
}
|
||||
|
||||
// ===== Activate =====
|
||||
|
||||
export async function activateScenePreset(presetId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/scene-presets/${presetId}/activate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
showToast(t('scenes.error.activate_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
const result = await resp.json();
|
||||
if (result.status === 'activated') {
|
||||
showToast(t('scenes.activated'), 'success');
|
||||
} else {
|
||||
showToast(`${t('scenes.activated_partial')}: ${result.errors.length} ${t('scenes.errors')}`, 'warning');
|
||||
}
|
||||
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('scenes.error.activate_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Recapture =====
|
||||
|
||||
export async function recaptureScenePreset(presetId) {
|
||||
const preset = _presetsCache.find(p => p.id === presetId);
|
||||
const name = preset ? preset.name : presetId;
|
||||
const confirmed = await showConfirm(t('scenes.recapture_confirm', { name }));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/scene-presets/${presetId}/recapture`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast(t('scenes.recaptured'), 'success');
|
||||
_reloadScenesTab();
|
||||
} else {
|
||||
showToast(t('scenes.error.recapture_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('scenes.error.recapture_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Delete =====
|
||||
|
||||
export async function deleteScenePreset(presetId) {
|
||||
const preset = _presetsCache.find(p => p.id === presetId);
|
||||
const name = preset ? preset.name : presetId;
|
||||
const confirmed = await showConfirm(t('scenes.delete_confirm', { name }));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/scene-presets/${presetId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast(t('scenes.deleted'), 'success');
|
||||
_reloadScenesTab();
|
||||
} else {
|
||||
showToast(t('scenes.error.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('scenes.error.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
function _reloadScenesTab() {
|
||||
// Reload the scenes tab if it's active
|
||||
if ((localStorage.getItem('activeTab') || 'dashboard') === 'scenes') {
|
||||
loadScenes();
|
||||
}
|
||||
// Also refresh dashboard (scene presets section)
|
||||
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
|
||||
}
|
||||
Reference in New Issue
Block a user