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:
2026-02-28 16:57:42 +03:00
parent bd8d7a019f
commit 2e747b5ece
38 changed files with 2269 additions and 32 deletions

View 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')}">&#x2715;</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);
}