Add demo mode: virtual hardware sandbox for testing without real devices

Demo mode provides a complete sandbox environment with:
- Virtual capture engine (radial rainbow test pattern on 3 displays)
- Virtual audio engine (synthetic music-like audio on 2 devices)
- Virtual LED device provider (strip/60, matrix/256, ring/24 LEDs)
- Isolated data directory (data/demo/) with auto-seeded sample entities
- Dedicated config (config/demo_config.yaml) with pre-configured API key
- Frontend indicator (DEMO badge + dismissible banner)
- Engine filtering (only demo engines visible in demo mode)
- Separate entry point: python -m wled_controller.demo (port 8081)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 16:17:14 +03:00
parent 81b275979b
commit 2240471b67
36 changed files with 1548 additions and 282 deletions

View File

@@ -129,6 +129,59 @@ h2 {
50% { opacity: 0.5; }
}
/* Demo mode badge (header) */
.demo-badge {
font-family: 'Orbitron', sans-serif;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.08em;
color: #1a1a1a;
background: #ffb300;
padding: 2px 10px;
border-radius: 10px;
text-transform: uppercase;
animation: demoPulse 3s ease-in-out infinite;
white-space: nowrap;
}
@keyframes demoPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* Demo mode banner (top of page) */
.demo-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 8px 16px;
background: linear-gradient(90deg, #ff8f00, #ffb300);
color: #1a1a1a;
font-size: 0.85rem;
font-weight: 500;
text-align: center;
z-index: var(--z-sticky, 100);
position: relative;
}
.demo-banner-dismiss {
background: none;
border: none;
color: #1a1a1a;
font-size: 1.2rem;
cursor: pointer;
padding: 0 4px;
opacity: 0.6;
transition: opacity 0.2s;
line-height: 1;
flex-shrink: 0;
}
.demo-banner-dismiss:hover {
opacity: 1;
}
/* Connection lost overlay */
.connection-overlay {
position: fixed;

View File

@@ -181,6 +181,8 @@ function _setConnectionState(online: boolean) {
return changed;
}
export let demoMode = false;
export async function loadServerInfo() {
try {
const response = await fetch('/health', { signal: AbortSignal.timeout(5000) });
@@ -194,6 +196,18 @@ export async function loadServerInfo() {
// Server came back — reload data
window.dispatchEvent(new CustomEvent('server:reconnected'));
}
// Demo mode detection
if (data.demo_mode && !demoMode) {
demoMode = true;
document.body.dataset.demo = 'true';
const badge = document.getElementById('demo-badge');
if (badge) badge.style.display = '';
const banner = document.getElementById('demo-banner');
if (banner && localStorage.getItem('demo-banner-dismissed') !== 'true') {
banner.style.display = '';
}
}
} catch (error) {
console.error('Failed to load server info:', error);
_setConnectionState(false);

View File

@@ -29,6 +29,8 @@ export function navigateToCard(tab: string, subTab: string | null, sectionKey: s
window.switchTargetSubTab(subTab);
} else if (tab === 'streams' && typeof window.switchStreamTab === 'function') {
window.switchStreamTab(subTab);
} else if (tab === 'automations' && typeof window.switchAutomationTab === 'function') {
window.switchAutomationTab(subTab);
}
}

View File

@@ -8,7 +8,7 @@ import { t } from '../core/i18n.ts';
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { CardSection } from '../core/card-sections.ts';
import { updateTabBadge } from './tabs.ts';
import { updateTabBadge, updateSubTabHash } from './tabs.ts';
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
@@ -17,6 +17,7 @@ import { getBaseOrigin } from './settings.ts';
import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { attachProcessPicker } from '../core/process-picker.ts';
import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard } from './scene-presets.ts';
import type { Automation } from '../types.ts';
@@ -85,6 +86,29 @@ const csAutomations = new CardSection('automations', { titleKey: 'automations.ti
{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteAutomations },
] } as any);
// ── Tree navigation ──
let _automationsTreeTriggered = false;
const _automationsTree = new TreeNav('automations-tree-nav', {
onSelect: (key: string) => {
_automationsTreeTriggered = true;
switchAutomationTab(key);
_automationsTreeTriggered = false;
}
});
export function switchAutomationTab(tabKey: string) {
document.querySelectorAll('.automation-sub-tab-panel').forEach(panel =>
(panel as HTMLElement).classList.toggle('active', panel.id === `automation-tab-${tabKey}`)
);
localStorage.setItem('activeAutomationTab', tabKey);
updateSubTabHash('automations', tabKey);
if (!_automationsTreeTriggered) {
_automationsTree.setActive(tabKey);
}
}
/* ── Condition logic IconSelect ───────────────────────────────── */
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -147,18 +171,34 @@ function renderAutomations(automations: any, sceneMap: any) {
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) })));
const activeTab = localStorage.getItem('activeAutomationTab') || 'automations';
const treeItems = [
{ key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.length },
{ key: 'scenes', icon: ICON_SCENE, titleKey: 'scenes.title', count: scenePresetsCache.data.length },
];
if (csAutomations.isMounted()) {
_automationsTree.updateCounts({
automations: automations.length,
scenes: scenePresetsCache.data.length,
});
csAutomations.reconcile(autoItems);
csScenes.reconcile(sceneItems);
} else {
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
container.innerHTML = toolbar + csAutomations.render(autoItems) + csScenes.render(sceneItems);
csAutomations.bind();
csScenes.bind();
const panels = [
{ key: 'automations', html: csAutomations.render(autoItems) },
{ key: 'scenes', html: csScenes.render(sceneItems) },
].map(p => `<div class="automation-sub-tab-panel stream-tab-panel${p.key === activeTab ? ' active' : ''}" id="automation-tab-${p.key}">${p.html}</div>`).join('');
// Localize data-i18n elements within the container
container.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n'));
container.innerHTML = panels;
CardSection.bindAll([csAutomations, csScenes]);
_automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_automationsTree.update(treeItems, activeTab);
_automationsTree.observeSections('automations-content', {
'automations': 'automations',
'scenes': 'scenes',
});
}
}

View File

@@ -48,6 +48,8 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }:
? (localStorage.getItem('activeTargetSubTab') || 'led')
: name === 'streams'
? (localStorage.getItem('activeStreamTab') || 'raw')
: name === 'automations'
? (localStorage.getItem('activeAutomationTab') || 'automations')
: null;
_setHash(name, subTab);
}
@@ -87,6 +89,7 @@ export function initTabs(): void {
if (hashRoute.subTab) {
if (saved === 'targets') localStorage.setItem('activeTargetSubTab', hashRoute.subTab);
if (saved === 'streams') localStorage.setItem('activeStreamTab', hashRoute.subTab);
if (saved === 'automations') localStorage.setItem('activeAutomationTab', hashRoute.subTab);
}
} else {
saved = localStorage.getItem('activeTab');

View File

@@ -4,6 +4,8 @@
"app.api_docs": "API Documentation",
"app.connection_lost": "Server unreachable",
"app.connection_retrying": "Attempting to reconnect…",
"demo.badge": "DEMO",
"demo.banner": "You're in demo mode — all devices and data are virtual. No real hardware is used.",
"theme.toggle": "Toggle theme",
"bg.anim.toggle": "Toggle ambient background",
"accent.title": "Accent color",

View File

@@ -4,6 +4,8 @@
"app.api_docs": "Документация API",
"app.connection_lost": "Сервер недоступен",
"app.connection_retrying": "Попытка переподключения…",
"demo.badge": "ДЕМО",
"demo.banner": "Вы в демо-режиме — все устройства и данные виртуальные. Реальное оборудование не используется.",
"theme.toggle": "Переключить тему",
"bg.anim.toggle": "Анимированный фон",
"accent.title": "Цвет акцента",

View File

@@ -4,6 +4,8 @@
"app.api_docs": "API 文档",
"app.connection_lost": "服务器不可达",
"app.connection_retrying": "正在尝试重新连接…",
"demo.badge": "演示",
"demo.banner": "您正处于演示模式 — 所有设备和数据均为虚拟。未使用任何真实硬件。",
"theme.toggle": "切换主题",
"bg.anim.toggle": "切换动态背景",
"accent.title": "主题色",