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:
@@ -13,6 +13,7 @@ import {
|
||||
ICON_TARGET, ICON_PROFILE, ICON_CLOCK, ICON_WARNING, ICON_OK,
|
||||
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP,
|
||||
} from '../core/icons.js';
|
||||
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js';
|
||||
|
||||
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
|
||||
const MAX_FPS_SAMPLES = 120;
|
||||
@@ -373,13 +374,14 @@ export async function loadDashboard(forceFullRender = false) {
|
||||
|
||||
try {
|
||||
// Fire all requests in a single batch to avoid sequential RTTs
|
||||
const [targetsResp, profilesResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp] = await Promise.all([
|
||||
const [targetsResp, profilesResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp, scenePresets] = await Promise.all([
|
||||
fetchWithAuth('/picture-targets'),
|
||||
fetchWithAuth('/profiles').catch(() => null),
|
||||
fetchWithAuth('/devices').catch(() => null),
|
||||
fetchWithAuth('/color-strip-sources').catch(() => null),
|
||||
fetchWithAuth('/picture-targets/batch/states').catch(() => null),
|
||||
fetchWithAuth('/picture-targets/batch/metrics').catch(() => null),
|
||||
loadScenePresets(),
|
||||
]);
|
||||
|
||||
const targetsData = await targetsResp.json();
|
||||
@@ -401,7 +403,7 @@ export async function loadDashboard(forceFullRender = false) {
|
||||
let runningIds = [];
|
||||
let newAutoStartIds = '';
|
||||
|
||||
if (targets.length === 0 && profiles.length === 0) {
|
||||
if (targets.length === 0 && profiles.length === 0 && scenePresets.length === 0) {
|
||||
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
||||
} else {
|
||||
const enriched = targets.map(target => ({
|
||||
@@ -496,6 +498,17 @@ export async function loadDashboard(forceFullRender = false) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Scene Presets section
|
||||
if (scenePresets.length > 0) {
|
||||
const sceneSec = renderScenePresetsSection(scenePresets);
|
||||
if (sceneSec) {
|
||||
dynamicHtml += `<div class="dashboard-section">
|
||||
${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)}
|
||||
${_sectionContent('scenes', sceneSec.content)}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (targets.length > 0) {
|
||||
let targetsInner = '';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
_discoveryScanRunning, set_discoveryScanRunning,
|
||||
_discoveryCache, set_discoveryCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, escapeHtml } from '../core/api.js';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
@@ -43,7 +43,29 @@ export function onDeviceTypeChanged() {
|
||||
const ledTypeGroup = document.getElementById('device-led-type-group');
|
||||
const sendLatencyGroup = document.getElementById('device-send-latency-group');
|
||||
|
||||
if (isMockDevice(deviceType)) {
|
||||
// URL label / hint / placeholder — adapt per device type
|
||||
const urlLabel = document.getElementById('device-url-label');
|
||||
const urlHint = document.getElementById('device-url-hint');
|
||||
|
||||
const scanBtn = document.getElementById('scan-network-btn');
|
||||
|
||||
if (isMqttDevice(deviceType)) {
|
||||
// MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery
|
||||
urlGroup.style.display = '';
|
||||
urlInput.setAttribute('required', '');
|
||||
serialGroup.style.display = 'none';
|
||||
serialSelect.removeAttribute('required');
|
||||
ledCountGroup.style.display = '';
|
||||
baudRateGroup.style.display = 'none';
|
||||
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
||||
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
||||
if (discoverySection) discoverySection.style.display = 'none';
|
||||
if (scanBtn) scanBtn.style.display = 'none';
|
||||
// Relabel URL field as "Topic"
|
||||
if (urlLabel) urlLabel.textContent = t('device.mqtt_topic');
|
||||
if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint');
|
||||
urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room';
|
||||
} else if (isMockDevice(deviceType)) {
|
||||
urlGroup.style.display = 'none';
|
||||
urlInput.removeAttribute('required');
|
||||
serialGroup.style.display = 'none';
|
||||
@@ -53,6 +75,7 @@ export function onDeviceTypeChanged() {
|
||||
if (ledTypeGroup) ledTypeGroup.style.display = '';
|
||||
if (sendLatencyGroup) sendLatencyGroup.style.display = '';
|
||||
if (discoverySection) discoverySection.style.display = 'none';
|
||||
if (scanBtn) scanBtn.style.display = 'none';
|
||||
} else if (isSerialDevice(deviceType)) {
|
||||
urlGroup.style.display = 'none';
|
||||
urlInput.removeAttribute('required');
|
||||
@@ -62,6 +85,7 @@ export function onDeviceTypeChanged() {
|
||||
baudRateGroup.style.display = '';
|
||||
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
||||
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
||||
if (scanBtn) scanBtn.style.display = 'none';
|
||||
// Hide discovery list — serial port dropdown replaces it
|
||||
if (discoverySection) discoverySection.style.display = 'none';
|
||||
// Populate from cache or show placeholder (lazy-load on focus)
|
||||
@@ -85,6 +109,11 @@ export function onDeviceTypeChanged() {
|
||||
baudRateGroup.style.display = 'none';
|
||||
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
||||
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
||||
if (scanBtn) scanBtn.style.display = '';
|
||||
// Restore default URL label/hint/placeholder
|
||||
if (urlLabel) urlLabel.textContent = t('device.url');
|
||||
if (urlHint) urlHint.textContent = t('device.url.hint');
|
||||
urlInput.placeholder = t('device.url.placeholder') || 'http://192.168.1.100';
|
||||
// Show cached results or trigger scan for WLED
|
||||
if (deviceType in _discoveryCache) {
|
||||
_renderDiscoveryList();
|
||||
@@ -316,6 +345,11 @@ export async function handleAddDevice(event) {
|
||||
url = document.getElementById('device-url').value.trim();
|
||||
}
|
||||
|
||||
// MQTT: ensure mqtt:// prefix
|
||||
if (isMqttDevice(deviceType) && url && !url.startsWith('mqtt://')) {
|
||||
url = 'mqtt://' + url;
|
||||
}
|
||||
|
||||
if (!name || (!isMockDevice(deviceType) && !url)) {
|
||||
error.textContent = t('device_discovery.error.fill_all_fields');
|
||||
error.style.display = 'block';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import {
|
||||
_deviceBrightnessCache, updateDeviceBrightness,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice } from '../core/api.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
@@ -174,22 +174,36 @@ export async function showSettings(deviceId) {
|
||||
document.getElementById('settings-health-interval').value = device.state_check_interval ?? 30;
|
||||
|
||||
const isMock = isMockDevice(device.device_type);
|
||||
const isMqtt = isMqttDevice(device.device_type);
|
||||
const urlGroup = document.getElementById('settings-url-group');
|
||||
const serialGroup = document.getElementById('settings-serial-port-group');
|
||||
const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]');
|
||||
const urlHint = urlGroup.querySelector('.input-hint');
|
||||
const urlInput = document.getElementById('settings-device-url');
|
||||
if (isMock) {
|
||||
urlGroup.style.display = 'none';
|
||||
document.getElementById('settings-device-url').removeAttribute('required');
|
||||
urlInput.removeAttribute('required');
|
||||
serialGroup.style.display = 'none';
|
||||
} else if (isAdalight) {
|
||||
urlGroup.style.display = 'none';
|
||||
document.getElementById('settings-device-url').removeAttribute('required');
|
||||
urlInput.removeAttribute('required');
|
||||
serialGroup.style.display = '';
|
||||
_populateSettingsSerialPorts(device.url);
|
||||
} else {
|
||||
urlGroup.style.display = '';
|
||||
document.getElementById('settings-device-url').setAttribute('required', '');
|
||||
document.getElementById('settings-device-url').value = device.url;
|
||||
urlInput.setAttribute('required', '');
|
||||
urlInput.value = device.url;
|
||||
serialGroup.style.display = 'none';
|
||||
// Relabel for MQTT
|
||||
if (isMqtt) {
|
||||
if (urlLabel) urlLabel.textContent = t('device.mqtt_topic');
|
||||
if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint');
|
||||
urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room';
|
||||
} else {
|
||||
if (urlLabel) urlLabel.textContent = t('device.url');
|
||||
if (urlHint) urlHint.textContent = t('settings.url.hint');
|
||||
urlInput.placeholder = t('device.url.placeholder') || 'http://192.168.1.100';
|
||||
}
|
||||
}
|
||||
|
||||
const ledCountGroup = document.getElementById('settings-led-count-group');
|
||||
|
||||
@@ -9,7 +9,7 @@ import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { CardSection } from '../core/card-sections.js';
|
||||
import { updateTabBadge } from './tabs.js';
|
||||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_TARGET, ICON_PROFILE, ICON_HELP, ICON_OK } from '../core/icons.js';
|
||||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_TARGET, ICON_PROFILE, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO } from '../core/icons.js';
|
||||
|
||||
class ProfileEditorModal extends Modal {
|
||||
constructor() { super('profile-editor-modal'); }
|
||||
@@ -116,6 +116,20 @@ function createProfileCard(profile, runningTargetIds = new Set()) {
|
||||
const matchLabel = t('profiles.condition.application.match_type.' + (c.match_type || 'running'));
|
||||
return `<span class="stream-card-prop stream-card-prop-full">${t('profiles.condition.application')}: ${apps} (${matchLabel})</span>`;
|
||||
}
|
||||
if (c.condition_type === 'time_of_day') {
|
||||
return `<span class="stream-card-prop">${ICON_CLOCK} ${c.start_time || '00:00'} – ${c.end_time || '23:59'}</span>`;
|
||||
}
|
||||
if (c.condition_type === 'system_idle') {
|
||||
const mode = c.when_idle !== false ? t('profiles.condition.system_idle.when_idle') : t('profiles.condition.system_idle.when_active');
|
||||
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`;
|
||||
}
|
||||
if (c.condition_type === 'display_state') {
|
||||
const stateLabel = t('profiles.condition.display_state.' + (c.state || 'on'));
|
||||
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('profiles.condition.display_state')}: ${stateLabel}</span>`;
|
||||
}
|
||||
if (c.condition_type === 'mqtt') {
|
||||
return `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('profiles.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`;
|
||||
}
|
||||
return `<span class="stream-card-prop">${c.condition_type}</span>`;
|
||||
});
|
||||
const logicLabel = profile.condition_logic === 'and' ? t('profiles.logic.and') : t('profiles.logic.or');
|
||||
@@ -259,6 +273,10 @@ function addProfileConditionRow(condition) {
|
||||
<select class="condition-type-select">
|
||||
<option value="always" ${condType === 'always' ? 'selected' : ''}>${t('profiles.condition.always')}</option>
|
||||
<option value="application" ${condType === 'application' ? 'selected' : ''}>${t('profiles.condition.application')}</option>
|
||||
<option value="time_of_day" ${condType === 'time_of_day' ? 'selected' : ''}>${t('profiles.condition.time_of_day')}</option>
|
||||
<option value="system_idle" ${condType === 'system_idle' ? 'selected' : ''}>${t('profiles.condition.system_idle')}</option>
|
||||
<option value="display_state" ${condType === 'display_state' ? 'selected' : ''}>${t('profiles.condition.display_state')}</option>
|
||||
<option value="mqtt" ${condType === 'mqtt' ? 'selected' : ''}>${t('profiles.condition.mqtt')}</option>
|
||||
</select>
|
||||
<button type="button" class="btn-remove-condition" onclick="this.closest('.profile-condition-row').remove()" title="Remove">✕</button>
|
||||
</div>
|
||||
@@ -273,6 +291,81 @@ function addProfileConditionRow(condition) {
|
||||
container.innerHTML = `<small class="condition-always-desc">${t('profiles.condition.always.hint')}</small>`;
|
||||
return;
|
||||
}
|
||||
if (type === 'time_of_day') {
|
||||
const startTime = data.start_time || '00:00';
|
||||
const endTime = data.end_time || '23:59';
|
||||
container.innerHTML = `
|
||||
<div class="condition-fields">
|
||||
<div class="condition-field">
|
||||
<label>${t('profiles.condition.time_of_day.start_time')}</label>
|
||||
<input type="time" class="condition-start-time" value="${startTime}">
|
||||
</div>
|
||||
<div class="condition-field">
|
||||
<label>${t('profiles.condition.time_of_day.end_time')}</label>
|
||||
<input type="time" class="condition-end-time" value="${endTime}">
|
||||
</div>
|
||||
<small class="condition-always-desc">${t('profiles.condition.time_of_day.overnight_hint')}</small>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
if (type === 'system_idle') {
|
||||
const idleMinutes = data.idle_minutes ?? 5;
|
||||
const whenIdle = data.when_idle ?? true;
|
||||
container.innerHTML = `
|
||||
<div class="condition-fields">
|
||||
<div class="condition-field">
|
||||
<label>${t('profiles.condition.system_idle.idle_minutes')}</label>
|
||||
<input type="number" class="condition-idle-minutes" min="1" max="999" value="${idleMinutes}">
|
||||
</div>
|
||||
<div class="condition-field">
|
||||
<label>${t('profiles.condition.system_idle.mode')}</label>
|
||||
<select class="condition-when-idle">
|
||||
<option value="true" ${whenIdle ? 'selected' : ''}>${t('profiles.condition.system_idle.when_idle')}</option>
|
||||
<option value="false" ${!whenIdle ? 'selected' : ''}>${t('profiles.condition.system_idle.when_active')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
if (type === 'display_state') {
|
||||
const dState = data.state || 'on';
|
||||
container.innerHTML = `
|
||||
<div class="condition-fields">
|
||||
<div class="condition-field">
|
||||
<label>${t('profiles.condition.display_state.state')}</label>
|
||||
<select class="condition-display-state">
|
||||
<option value="on" ${dState === 'on' ? 'selected' : ''}>${t('profiles.condition.display_state.on')}</option>
|
||||
<option value="off" ${dState === 'off' ? 'selected' : ''}>${t('profiles.condition.display_state.off')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
if (type === 'mqtt') {
|
||||
const topic = data.topic || '';
|
||||
const payload = data.payload || '';
|
||||
const matchMode = data.match_mode || 'exact';
|
||||
container.innerHTML = `
|
||||
<div class="condition-fields">
|
||||
<div class="condition-field">
|
||||
<label>${t('profiles.condition.mqtt.topic')}</label>
|
||||
<input type="text" class="condition-mqtt-topic" value="${escapeHtml(topic)}" placeholder="home/status/power">
|
||||
</div>
|
||||
<div class="condition-field">
|
||||
<label>${t('profiles.condition.mqtt.payload')}</label>
|
||||
<input type="text" class="condition-mqtt-payload" value="${escapeHtml(payload)}" placeholder="ON">
|
||||
</div>
|
||||
<div class="condition-field">
|
||||
<label>${t('profiles.condition.mqtt.match_mode')}</label>
|
||||
<select class="condition-mqtt-match-mode">
|
||||
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('profiles.condition.mqtt.match_mode.exact')}</option>
|
||||
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('profiles.condition.mqtt.match_mode.contains')}</option>
|
||||
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('profiles.condition.mqtt.match_mode.regex')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
const appsValue = (data.apps || []).join('\n');
|
||||
const matchType = data.match_type || 'running';
|
||||
container.innerHTML = `
|
||||
@@ -308,7 +401,7 @@ function addProfileConditionRow(condition) {
|
||||
|
||||
renderFields(condType, condition);
|
||||
typeSelect.addEventListener('change', () => {
|
||||
renderFields(typeSelect.value, { apps: [], match_type: 'running' });
|
||||
renderFields(typeSelect.value, {});
|
||||
});
|
||||
|
||||
list.appendChild(row);
|
||||
@@ -382,6 +475,30 @@ function getProfileEditorConditions() {
|
||||
const condType = typeSelect ? typeSelect.value : 'application';
|
||||
if (condType === 'always') {
|
||||
conditions.push({ condition_type: 'always' });
|
||||
} else if (condType === 'time_of_day') {
|
||||
conditions.push({
|
||||
condition_type: 'time_of_day',
|
||||
start_time: row.querySelector('.condition-start-time').value || '00:00',
|
||||
end_time: row.querySelector('.condition-end-time').value || '23:59',
|
||||
});
|
||||
} else if (condType === 'system_idle') {
|
||||
conditions.push({
|
||||
condition_type: 'system_idle',
|
||||
idle_minutes: parseInt(row.querySelector('.condition-idle-minutes').value, 10) || 5,
|
||||
when_idle: row.querySelector('.condition-when-idle').value === 'true',
|
||||
});
|
||||
} else if (condType === 'display_state') {
|
||||
conditions.push({
|
||||
condition_type: 'display_state',
|
||||
state: row.querySelector('.condition-display-state').value || 'on',
|
||||
});
|
||||
} else if (condType === 'mqtt') {
|
||||
conditions.push({
|
||||
condition_type: 'mqtt',
|
||||
topic: row.querySelector('.condition-mqtt-topic').value.trim(),
|
||||
payload: row.querySelector('.condition-mqtt-payload').value,
|
||||
match_mode: row.querySelector('.condition-mqtt-match-mode').value || 'exact',
|
||||
});
|
||||
} else {
|
||||
const matchType = row.querySelector('.condition-match-type').value;
|
||||
const appsText = row.querySelector('.condition-apps').value.trim();
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -19,8 +19,12 @@ function _setHash(tab, subTab) {
|
||||
}
|
||||
|
||||
let _suppressHashUpdate = false;
|
||||
let _activeTab = null;
|
||||
|
||||
export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
|
||||
if (_activeTab === name) return;
|
||||
_activeTab = name;
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
const isActive = btn.dataset.tab === name;
|
||||
btn.classList.toggle('active', isActive);
|
||||
@@ -56,6 +60,8 @@ export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} else if (name === 'profiles') {
|
||||
if (typeof window.loadProfiles === 'function') window.loadProfiles();
|
||||
} else if (name === 'scenes') {
|
||||
if (typeof window.loadScenes === 'function') window.loadScenes();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user