Replace auto-start with startup automation, add card colors to dashboard

- Add `startup` automation condition type that activates on server boot,
  replacing the per-target `auto_start` flag
- Remove `auto_start` field from targets, scene snapshots, and all API layers
- Remove auto-start UI section and star buttons from dashboard and target cards
- Remove `color` field from scene presets (backend, API, modal, frontend)
- Add card color support to scene preset cards (color picker + border style)
- Show localStorage-backed card colors on all dashboard cards (targets,
  automations, sync clocks, scene presets)
- Fix card color picker updating wrong card when duplicate data attributes
  exist by using closest() from picker wrapper instead of global querySelector
- Add sync clocks step to Sources tab tutorial
- Bump SW cache v9 → v10

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 01:09:27 +03:00
parent f08117eb7b
commit fddbd771f2
28 changed files with 78 additions and 211 deletions

View File

@@ -24,6 +24,7 @@ from wled_controller.storage.automation import (
Condition, Condition,
DisplayStateCondition, DisplayStateCondition,
MQTTCondition, MQTTCondition,
StartupCondition,
SystemIdleCondition, SystemIdleCondition,
TimeOfDayCondition, TimeOfDayCondition,
WebhookCondition, WebhookCondition,
@@ -70,6 +71,8 @@ def _condition_from_schema(s: ConditionSchema) -> Condition:
return WebhookCondition( return WebhookCondition(
token=s.token or secrets.token_hex(16), token=s.token or secrets.token_hex(16),
) )
if s.condition_type == "startup":
return StartupCondition()
raise ValueError(f"Unknown condition type: {s.condition_type}") raise ValueError(f"Unknown condition type: {s.condition_type}")

View File

@@ -105,7 +105,7 @@ def _target_to_response(target) -> PictureTargetResponse:
adaptive_fps=target.adaptive_fps, adaptive_fps=target.adaptive_fps,
protocol=target.protocol, protocol=target.protocol,
description=target.description, description=target.description,
auto_start=target.auto_start,
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
) )
@@ -117,7 +117,7 @@ def _target_to_response(target) -> PictureTargetResponse:
picture_source_id=target.picture_source_id, picture_source_id=target.picture_source_id,
key_colors_settings=_kc_settings_to_schema(target.settings), key_colors_settings=_kc_settings_to_schema(target.settings),
description=target.description, description=target.description,
auto_start=target.auto_start,
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
) )
@@ -127,7 +127,7 @@ def _target_to_response(target) -> PictureTargetResponse:
name=target.name, name=target.name,
target_type=target.target_type, target_type=target.target_type,
description=target.description, description=target.description,
auto_start=target.auto_start,
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
) )
@@ -169,7 +169,6 @@ async def create_target(
picture_source_id=data.picture_source_id, picture_source_id=data.picture_source_id,
key_colors_settings=kc_settings, key_colors_settings=kc_settings,
description=data.description, description=data.description,
auto_start=data.auto_start,
) )
# Register in processor manager # Register in processor manager
@@ -288,7 +287,6 @@ async def update_target(
protocol=data.protocol, protocol=data.protocol,
key_colors_settings=kc_settings, key_colors_settings=kc_settings,
description=data.description, description=data.description,
auto_start=data.auto_start,
) )
# Detect KC brightness VS change (inside key_colors_settings) # Detect KC brightness VS change (inside key_colors_settings)

View File

@@ -37,14 +37,12 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
id=preset.id, id=preset.id,
name=preset.name, name=preset.name,
description=preset.description, description=preset.description,
color=preset.color,
targets=[{ targets=[{
"target_id": t.target_id, "target_id": t.target_id,
"running": t.running, "running": t.running,
"color_strip_source_id": t.color_strip_source_id, "color_strip_source_id": t.color_strip_source_id,
"brightness_value_source_id": t.brightness_value_source_id, "brightness_value_source_id": t.brightness_value_source_id,
"fps": t.fps, "fps": t.fps,
"auto_start": t.auto_start,
} for t in preset.targets], } for t in preset.targets],
order=preset.order, order=preset.order,
created_at=preset.created_at, created_at=preset.created_at,
@@ -76,7 +74,6 @@ async def create_scene_preset(
id=f"scene_{uuid.uuid4().hex[:8]}", id=f"scene_{uuid.uuid4().hex[:8]}",
name=data.name, name=data.name,
description=data.description, description=data.description,
color=data.color,
targets=targets, targets=targets,
order=store.count(), order=store.count(),
created_at=now, created_at=now,
@@ -143,7 +140,6 @@ async def update_scene_preset(
preset_id, preset_id,
name=data.name, name=data.name,
description=data.description, description=data.description,
color=data.color,
order=data.order, order=data.order,
) )
except ValueError as e: except ValueError as e:

View File

@@ -65,7 +65,6 @@ class PictureTargetCreate(BaseModel):
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)") picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
auto_start: bool = Field(default=False, description="Auto-start on server boot")
class PictureTargetUpdate(BaseModel): class PictureTargetUpdate(BaseModel):
@@ -86,7 +85,6 @@ class PictureTargetUpdate(BaseModel):
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)") picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
auto_start: Optional[bool] = Field(None, description="Auto-start on server boot")
class PictureTargetResponse(BaseModel): class PictureTargetResponse(BaseModel):
@@ -109,7 +107,6 @@ class PictureTargetResponse(BaseModel):
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)") picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
auto_start: bool = Field(default=False, description="Auto-start on server boot")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")

View File

@@ -12,7 +12,6 @@ class TargetSnapshotSchema(BaseModel):
color_strip_source_id: str = "" color_strip_source_id: str = ""
brightness_value_source_id: str = "" brightness_value_source_id: str = ""
fps: int = 30 fps: int = 30
auto_start: bool = False
class ScenePresetCreate(BaseModel): class ScenePresetCreate(BaseModel):
@@ -20,7 +19,6 @@ class ScenePresetCreate(BaseModel):
name: str = Field(description="Preset name", min_length=1, max_length=100) name: str = Field(description="Preset name", min_length=1, max_length=100)
description: str = Field(default="", max_length=500) description: str = Field(default="", max_length=500)
color: str = Field(default="#4fc3f7", description="Card accent color")
target_ids: Optional[List[str]] = Field(None, description="Target IDs to capture (all if omitted)") target_ids: Optional[List[str]] = Field(None, description="Target IDs to capture (all if omitted)")
@@ -29,7 +27,6 @@ class ScenePresetUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100) name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500) description: Optional[str] = Field(None, max_length=500)
color: Optional[str] = None
order: Optional[int] = None order: Optional[int] = None
@@ -39,7 +36,6 @@ class ScenePresetResponse(BaseModel):
id: str id: str
name: str name: str
description: str description: str
color: str
targets: List[TargetSnapshotSchema] targets: List[TargetSnapshotSchema]
order: int order: int
created_at: datetime created_at: datetime

View File

@@ -13,6 +13,7 @@ from wled_controller.storage.automation import (
Condition, Condition,
DisplayStateCondition, DisplayStateCondition,
MQTTCondition, MQTTCondition,
StartupCondition,
SystemIdleCondition, SystemIdleCondition,
TimeOfDayCondition, TimeOfDayCondition,
WebhookCondition, WebhookCondition,
@@ -204,7 +205,7 @@ class AutomationEngine:
fullscreen_procs: Set[str], fullscreen_procs: Set[str],
idle_seconds: Optional[float], display_state: Optional[str], idle_seconds: Optional[float], display_state: Optional[str],
) -> bool: ) -> bool:
if isinstance(condition, AlwaysCondition): if isinstance(condition, (AlwaysCondition, StartupCondition)):
return True return True
if isinstance(condition, ApplicationCondition): if isinstance(condition, ApplicationCondition):
return self._evaluate_app_condition(condition, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs) return self._evaluate_app_condition(condition, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs)

View File

@@ -38,7 +38,6 @@ def capture_current_snapshot(
color_strip_source_id=getattr(t, "color_strip_source_id", ""), color_strip_source_id=getattr(t, "color_strip_source_id", ""),
brightness_value_source_id=getattr(t, "brightness_value_source_id", ""), brightness_value_source_id=getattr(t, "brightness_value_source_id", ""),
fps=getattr(t, "fps", 30), fps=getattr(t, "fps", 30),
auto_start=getattr(t, "auto_start", False),
)) ))
return targets return targets
@@ -83,9 +82,6 @@ async def apply_scene_state(
changed["brightness_value_source_id"] = ts.brightness_value_source_id changed["brightness_value_source_id"] = ts.brightness_value_source_id
if getattr(target, "fps", None) != ts.fps: if getattr(target, "fps", None) != ts.fps:
changed["fps"] = ts.fps changed["fps"] = ts.fps
if getattr(target, "auto_start", None) != ts.auto_start:
changed["auto_start"] = ts.auto_start
if changed: if changed:
target.update_fields(**changed) target.update_fields(**changed)
target_store.update_target(ts.target_id, **changed) target_store.update_target(ts.target_id, **changed)

View File

@@ -188,19 +188,6 @@ async def lifespan(app: FastAPI):
# Start auto-backup engine (periodic configuration backups) # Start auto-backup engine (periodic configuration backups)
await auto_backup_engine.start() await auto_backup_engine.start()
# Auto-start targets with auto_start=True
auto_started = 0
for target in targets:
if getattr(target, "auto_start", False):
try:
await processor_manager.start_processing(target.id)
auto_started += 1
logger.info(f"Auto-started target: {target.name} ({target.id})")
except Exception as e:
logger.warning(f"Failed to auto-start target {target.id}: {e}")
if auto_started:
logger.info(f"Auto-started {auto_started} target(s)")
yield yield
# Shutdown # Shutdown

View File

@@ -36,7 +36,7 @@ import {
} from './features/devices.js'; } from './features/devices.js';
import { import {
loadDashboard, stopUptimeTimer, loadDashboard, stopUptimeTimer,
dashboardToggleAutomation, dashboardStartTarget, dashboardStopTarget, dashboardToggleAutoStart, dashboardStopAll, dashboardToggleAutomation, dashboardStartTarget, dashboardStopTarget, dashboardStopAll,
dashboardPauseClock, dashboardResumeClock, dashboardResetClock, dashboardPauseClock, dashboardResumeClock, dashboardResetClock,
toggleDashboardSection, changeDashboardPollInterval, toggleDashboardSection, changeDashboardPollInterval,
} from './features/dashboard.js'; } from './features/dashboard.js';
@@ -100,7 +100,7 @@ import {
startTargetProcessing, stopTargetProcessing, startTargetProcessing, stopTargetProcessing,
stopAllLedTargets, stopAllKCTargets, stopAllLedTargets, stopAllKCTargets,
startTargetOverlay, stopTargetOverlay, deleteTarget, startTargetOverlay, stopTargetOverlay, deleteTarget,
cloneTarget, toggleLedPreview, toggleTargetAutoStart, cloneTarget, toggleLedPreview,
expandAllTargetSections, collapseAllTargetSections, expandAllTargetSections, collapseAllTargetSections,
disconnectAllLedPreviewWS, disconnectAllLedPreviewWS,
} from './features/targets.js'; } from './features/targets.js';
@@ -211,7 +211,6 @@ Object.assign(window, {
dashboardToggleAutomation, dashboardToggleAutomation,
dashboardStartTarget, dashboardStartTarget,
dashboardStopTarget, dashboardStopTarget,
dashboardToggleAutoStart,
dashboardStopAll, dashboardStopAll,
dashboardPauseClock, dashboardPauseClock,
dashboardResumeClock, dashboardResumeClock,
@@ -356,7 +355,6 @@ Object.assign(window, {
deleteTarget, deleteTarget,
cloneTarget, cloneTarget,
toggleLedPreview, toggleLedPreview,
toggleTargetAutoStart,
disconnectAllLedPreviewWS, disconnectAllLedPreviewWS,
// color-strip sources // color-strip sources

View File

@@ -59,7 +59,10 @@ export function cardColorButton(entityId, cardAttr) {
registerColorPicker(pickerId, (hex) => { registerColorPicker(pickerId, (hex) => {
setCardColor(entityId, hex); setCardColor(entityId, hex);
const card = document.querySelector(`[${cardAttr}="${entityId}"]`); // Find the card that contains this picker (not a global querySelector
// which could match a dashboard compact card first)
const wrapper = document.getElementById(`cp-wrap-${pickerId}`);
const card = wrapper?.closest(`[${cardAttr}]`);
if (card) card.style.borderLeft = hex ? `3px solid ${hex}` : ''; if (card) card.style.borderLeft = hex ? `3px solid ${hex}` : '';
}); });

View File

@@ -113,6 +113,9 @@ function createAutomationCard(automation, sceneMap = new Map()) {
if (c.condition_type === 'always') { if (c.condition_type === 'always') {
return `<span class="stream-card-prop">${ICON_OK} ${t('automations.condition.always')}</span>`; return `<span class="stream-card-prop">${ICON_OK} ${t('automations.condition.always')}</span>`;
} }
if (c.condition_type === 'startup') {
return `<span class="stream-card-prop">${ICON_START} ${t('automations.condition.startup')}</span>`;
}
if (c.condition_type === 'application') { if (c.condition_type === 'application') {
const apps = (c.apps || []).join(', '); const apps = (c.apps || []).join(', ');
const matchLabel = t('automations.condition.application.match_type.' + (c.match_type || 'running')); const matchLabel = t('automations.condition.application.match_type.' + (c.match_type || 'running'));
@@ -384,6 +387,7 @@ function addAutomationConditionRow(condition) {
<div class="condition-header"> <div class="condition-header">
<select class="condition-type-select"> <select class="condition-type-select">
<option value="always" ${condType === 'always' ? 'selected' : ''}>${t('automations.condition.always')}</option> <option value="always" ${condType === 'always' ? 'selected' : ''}>${t('automations.condition.always')}</option>
<option value="startup" ${condType === 'startup' ? 'selected' : ''}>${t('automations.condition.startup')}</option>
<option value="application" ${condType === 'application' ? 'selected' : ''}>${t('automations.condition.application')}</option> <option value="application" ${condType === 'application' ? 'selected' : ''}>${t('automations.condition.application')}</option>
<option value="time_of_day" ${condType === 'time_of_day' ? 'selected' : ''}>${t('automations.condition.time_of_day')}</option> <option value="time_of_day" ${condType === 'time_of_day' ? 'selected' : ''}>${t('automations.condition.time_of_day')}</option>
<option value="system_idle" ${condType === 'system_idle' ? 'selected' : ''}>${t('automations.condition.system_idle')}</option> <option value="system_idle" ${condType === 'system_idle' ? 'selected' : ''}>${t('automations.condition.system_idle')}</option>
@@ -404,6 +408,10 @@ function addAutomationConditionRow(condition) {
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.always.hint')}</small>`; container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.always.hint')}</small>`;
return; return;
} }
if (type === 'startup') {
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.startup.hint')}</small>`;
return;
}
if (type === 'time_of_day') { if (type === 'time_of_day') {
const startTime = data.start_time || '00:00'; const startTime = data.start_time || '00:00';
const endTime = data.end_time || '23:59'; const endTime = data.end_time || '23:59';
@@ -612,6 +620,8 @@ function getAutomationEditorConditions() {
const condType = typeSelect ? typeSelect.value : 'application'; const condType = typeSelect ? typeSelect.value : 'application';
if (condType === 'always') { if (condType === 'always') {
conditions.push({ condition_type: 'always' }); conditions.push({ condition_type: 'always' });
} else if (condType === 'startup') {
conditions.push({ condition_type: 'startup' });
} else if (condType === 'time_of_day') { } else if (condType === 'time_of_day') {
conditions.push({ conditions.push({
condition_type: 'time_of_day', condition_type: 'time_of_day',

View File

@@ -10,9 +10,10 @@ import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling }
import { startAutoRefresh, updateTabBadge } from './tabs.js'; import { startAutoRefresh, updateTabBadge } from './tabs.js';
import { import {
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK, ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_AUTOSTART, ICON_HELP, ICON_SCENE, ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE,
} from '../core/icons.js'; } from '../core/icons.js';
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js'; import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js';
import { cardColorStyle } from '../core/card-colors.js';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
const MAX_FPS_SAMPLES = 120; const MAX_FPS_SAMPLES = 120;
@@ -21,7 +22,6 @@ let _fpsHistory = {}; // { targetId: number[] } — fps_actual
let _fpsCurrentHistory = {}; // { targetId: number[] } — fps_current let _fpsCurrentHistory = {}; // { targetId: number[] } — fps_current
let _fpsCharts = {}; // { targetId: Chart } let _fpsCharts = {}; // { targetId: Chart }
let _lastRunningIds = []; // sorted target IDs from previous render let _lastRunningIds = []; // sorted target IDs from previous render
let _lastAutoStartIds = ''; // comma-joined sorted auto-start IDs
let _lastSyncClockIds = ''; // comma-joined sorted sync clock IDs let _lastSyncClockIds = ''; // comma-joined sorted sync clock IDs
let _uptimeBase = {}; // { targetId: { seconds, timestamp } } let _uptimeBase = {}; // { targetId: { seconds, timestamp } }
let _uptimeTimer = null; let _uptimeTimer = null;
@@ -308,7 +308,8 @@ function renderDashboardSyncClock(clock) {
clock.description ? escapeHtml(clock.description) : '', clock.description ? escapeHtml(clock.description) : '',
].filter(Boolean).join(' · '); ].filter(Boolean).join(' · ');
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-sync-clock-id="${clock.id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','sync','sync-clocks','data-id','${clock.id}')}"> const scStyle = cardColorStyle(clock.id);
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-sync-clock-id="${clock.id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','sync','sync-clocks','data-id','${clock.id}')}"${scStyle ? ` style="${scStyle}"` : ''}>
<div class="dashboard-target-info"> <div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_CLOCK}</span> <span class="dashboard-target-icon">${ICON_CLOCK}</span>
<div> <div>
@@ -447,8 +448,6 @@ export async function loadDashboard(forceFullRender = false) {
// Build dynamic HTML (targets, automations) // Build dynamic HTML (targets, automations)
let dynamicHtml = ''; let dynamicHtml = '';
let runningIds = []; let runningIds = [];
let newAutoStartIds = '';
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0) { if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`; dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
} else { } else {
@@ -465,10 +464,9 @@ export async function loadDashboard(forceFullRender = false) {
// Check if we can do an in-place metrics update (same targets, not first load) // Check if we can do an in-place metrics update (same targets, not first load)
const newRunningIds = running.map(t => t.id).sort().join(','); const newRunningIds = running.map(t => t.id).sort().join(',');
const prevRunningIds = [..._lastRunningIds].sort().join(','); const prevRunningIds = [..._lastRunningIds].sort().join(',');
newAutoStartIds = enriched.filter(t => t.auto_start).map(t => t.id).sort().join(',');
const newSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(','); const newSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent'); const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent');
const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newAutoStartIds === _lastAutoStartIds && newSyncClockIds === _lastSyncClockIds; const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newSyncClockIds === _lastSyncClockIds;
if (structureUnchanged && !forceFullRender && running.length > 0) { if (structureUnchanged && !forceFullRender && running.length > 0) {
_updateRunningMetrics(running); _updateRunningMetrics(running);
_updateSyncClocksInPlace(syncClocks); _updateSyncClocksInPlace(syncClocks);
@@ -489,52 +487,6 @@ export async function loadDashboard(forceFullRender = false) {
return; return;
} }
const autoStartTargets = enriched.filter(t => t.auto_start);
if (autoStartTargets.length > 0) {
const autoStartCards = autoStartTargets.map(target => {
const isRunning = !!(target.state && target.state.processing);
const isLed = target.target_type !== 'key_colors';
const typeLabel = isLed ? t('dashboard.type.led') : t('dashboard.type.kc');
const subtitleParts = [typeLabel];
if (isLed) {
const device = target.device_id ? devicesMap[target.device_id] : null;
if (device) subtitleParts.push((device.device_type || '').toUpperCase());
const cssId = target.color_strip_source_id || '';
if (cssId) {
const css = cssSourceMap[cssId];
if (css) subtitleParts.push(t(`color_strip.type.${css.source_type}`) || css.source_type);
}
}
const statusBadge = isRunning
? `<span class="dashboard-badge-active">${t('automations.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('automations.status.inactive')}</span>`;
const subtitle = subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : '';
const asNavSub = isLed ? 'led' : 'key_colors';
const asNavSec = isLed ? 'led-targets' : 'kc-targets';
const asNavAttr = isLed ? 'data-target-id' : 'data-kc-target-id';
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-target-id="${target.id}" onclick="if(!event.target.closest('button')){navigateToCard('targets','${asNavSub}','${asNavSec}','${asNavAttr}','${target.id}')}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_AUTOSTART}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(target.name)} ${statusBadge}</div>
${subtitle}
</div>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn ${isRunning ? 'stop' : 'start'}" onclick="${isRunning ? `dashboardStopTarget('${target.id}')` : `dashboardStartTarget('${target.id}')`}" title="${isRunning ? t('device.stop') : t('device.start')}">
${isRunning ? ICON_STOP_PLAIN : ICON_START}
</button>
</div>
</div>`;
}).join('');
const autoStartItems = `<div class="dashboard-autostart-grid">${autoStartCards}</div>`;
dynamicHtml += `<div class="dashboard-section">
${_sectionHeader('autostart', t('autostart.title'), autoStartTargets.length)}
${_sectionContent('autostart', autoStartItems)}
</div>`;
}
if (automations.length > 0) { if (automations.length > 0) {
const activeAutomations = automations.filter(a => a.is_active); const activeAutomations = automations.filter(a => a.is_active);
const inactiveAutomations = automations.filter(a => !a.is_active); const inactiveAutomations = automations.filter(a => !a.is_active);
@@ -617,7 +569,6 @@ export async function loadDashboard(forceFullRender = false) {
} }
} }
_lastRunningIds = runningIds; _lastRunningIds = runningIds;
_lastAutoStartIds = newAutoStartIds;
_lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(','); _lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
_cacheUptimeElements(); _cacheUptimeElements();
await _initFpsCharts(runningIds); await _initFpsCharts(runningIds);
@@ -683,7 +634,8 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
healthDot = `<span class="health-dot ${cls}"></span>`; healthDot = `<span class="health-dot ${cls}"></span>`;
} }
return `<div class="dashboard-target dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}"> const cStyle = cardColorStyle(target.id);
return `<div class="dashboard-target dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle ? ` style="${cStyle}"` : ''}>
<div class="dashboard-target-info"> <div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span> <span class="dashboard-target-icon">${icon}</span>
<div> <div>
@@ -708,12 +660,12 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
</div> </div>
</div> </div>
<div class="dashboard-target-actions"> <div class="dashboard-target-actions">
<button class="dashboard-autostart-btn${target.auto_start ? ' active' : ''}" onclick="dashboardToggleAutoStart('${target.id}', ${!target.auto_start})" title="${target.auto_start ? t('autostart.toggle.enabled') : t('autostart.toggle.disabled')}">&#x2605;</button>
<button class="dashboard-action-btn stop" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">${ICON_STOP_PLAIN}</button> <button class="dashboard-action-btn stop" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">${ICON_STOP_PLAIN}</button>
</div> </div>
</div>`; </div>`;
} else { } else {
return `<div class="dashboard-target dashboard-card-link" onclick="${navOnclick}"> const cStyle2 = cardColorStyle(target.id);
return `<div class="dashboard-target dashboard-card-link" onclick="${navOnclick}"${cStyle2 ? ` style="${cStyle2}"` : ''}>
<div class="dashboard-target-info"> <div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span> <span class="dashboard-target-icon">${icon}</span>
<div> <div>
@@ -723,7 +675,6 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
</div> </div>
<div class="dashboard-target-metrics"></div> <div class="dashboard-target-metrics"></div>
<div class="dashboard-target-actions"> <div class="dashboard-target-actions">
<button class="dashboard-autostart-btn${target.auto_start ? ' active' : ''}" onclick="dashboardToggleAutoStart('${target.id}', ${!target.auto_start})" title="${target.auto_start ? t('autostart.toggle.enabled') : t('autostart.toggle.disabled')}">&#x2605;</button>
<button class="dashboard-action-btn start" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">${ICON_START}</button> <button class="dashboard-action-btn start" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">${ICON_START}</button>
</div> </div>
</div>`; </div>`;
@@ -742,6 +693,7 @@ function renderDashboardAutomation(automation, sceneMap = new Map()) {
const matchLabel = c.match_type === 'topmost' ? t('automations.condition.application.match_type.topmost') : t('automations.condition.application.match_type.running'); const matchLabel = c.match_type === 'topmost' ? t('automations.condition.application.match_type.topmost') : t('automations.condition.application.match_type.running');
return `${apps} (${matchLabel})`; return `${apps} (${matchLabel})`;
} }
if (c.condition_type === 'startup') return t('automations.condition.startup');
return c.condition_type; return c.condition_type;
}); });
const logic = automation.condition_logic === 'and' ? ' & ' : ' | '; const logic = automation.condition_logic === 'and' ? ' & ' : ' | ';
@@ -758,7 +710,8 @@ function renderDashboardAutomation(automation, sceneMap = new Map()) {
const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null; const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null;
const sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected'); const sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected');
return `<div class="dashboard-target dashboard-automation dashboard-card-link" data-automation-id="${automation.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'automations','data-automation-id','${automation.id}')}"> const aStyle = cardColorStyle(automation.id);
return `<div class="dashboard-target dashboard-automation dashboard-card-link" data-automation-id="${automation.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'automations','data-automation-id','${automation.id}')}"${aStyle ? ` style="${aStyle}"` : ''}>
<div class="dashboard-target-info"> <div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_AUTOMATION}</span> <span class="dashboard-target-icon">${ICON_AUTOMATION}</span>
<div> <div>
@@ -827,25 +780,6 @@ export async function dashboardStopTarget(targetId) {
} }
} }
export async function dashboardToggleAutoStart(targetId, enable) {
try {
const response = await fetchWithAuth(`/picture-targets/${targetId}`, {
method: 'PUT',
body: JSON.stringify({ auto_start: enable }),
});
if (response.ok) {
showToast(t(enable ? 'autostart.toggle.enabled' : 'autostart.toggle.disabled'), 'success');
loadDashboard();
} else {
const error = await response.json();
showToast(error.detail || t('dashboard.error.autostart_toggle_failed'), 'error');
}
} catch (error) {
if (error.isAuth) return;
showToast(t('dashboard.error.autostart_toggle_failed'), 'error');
}
}
export async function dashboardStopAll() { export async function dashboardStopAll() {
try { try {
const [targetsResp, statesResp] = await Promise.all([ const [targetsResp, statesResp] = await Promise.all([

View File

@@ -122,7 +122,6 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
return wrapCard({ return wrapCard({
dataAttr: 'data-kc-target-id', dataAttr: 'data-kc-target-id',
id: target.id, id: target.id,
topButtons: `<button class="card-autostart-btn${target.auto_start ? ' active' : ''}" onclick="toggleTargetAutoStart('${target.id}', ${!target.auto_start})" title="${target.auto_start ? t('autostart.toggle.enabled') : t('autostart.toggle.disabled')}">&#x2605;</button>`,
removeOnclick: `deleteKCTarget('${target.id}')`, removeOnclick: `deleteKCTarget('${target.id}')`,
removeTitle: t('common.delete'), removeTitle: t('common.delete'),
content: ` content: `

View File

@@ -12,6 +12,7 @@ import {
ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET,
} from '../core/icons.js'; } from '../core/icons.js';
import { scenePresetsCache } from '../core/state.js'; import { scenePresetsCache } from '../core/state.js';
import { cardColorStyle, cardColorButton } from '../core/card-colors.js';
let _editingId = null; let _editingId = null;
let _allTargets = []; // fetched on capture open let _allTargets = []; // fetched on capture open
@@ -24,7 +25,6 @@ class ScenePresetEditorModal extends Modal {
return { return {
name: document.getElementById('scene-preset-editor-name').value, name: document.getElementById('scene-preset-editor-name').value,
description: document.getElementById('scene-preset-editor-description').value, description: document.getElementById('scene-preset-editor-description').value,
color: document.getElementById('scene-preset-editor-color').value,
targets: items, targets: items,
}; };
} }
@@ -40,7 +40,6 @@ export const csScenes = new CardSection('scenes', {
export function createSceneCard(preset) { export function createSceneCard(preset) {
const targetCount = (preset.targets || []).length; const targetCount = (preset.targets || []).length;
const colorStyle = `border-left: 3px solid ${escapeHtml(preset.color || '#4fc3f7')}`;
const meta = [ const meta = [
targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null, targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null,
@@ -48,7 +47,8 @@ export function createSceneCard(preset) {
const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : ''; const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : '';
return `<div class="card" data-scene-id="${preset.id}" style="${colorStyle}"> const colorStyle = cardColorStyle(preset.id);
return `<div class="card" data-scene-id="${preset.id}"${colorStyle ? ` style="${colorStyle}"` : ''}>
<div class="card-top-actions"> <div class="card-top-actions">
<button class="card-remove-btn" onclick="deleteScenePreset('${preset.id}', '${escapeHtml(preset.name)}')" title="${t('scenes.delete')}">&#x2715;</button> <button class="card-remove-btn" onclick="deleteScenePreset('${preset.id}', '${escapeHtml(preset.name)}')" title="${t('scenes.delete')}">&#x2715;</button>
</div> </div>
@@ -64,6 +64,7 @@ export function createSceneCard(preset) {
<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="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-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> <button class="btn btn-icon btn-success" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
${cardColorButton(preset.id, 'data-scene-id')}
</div> </div>
</div>`; </div>`;
} }
@@ -84,14 +85,14 @@ export function renderScenePresetsSection(presets) {
} }
function _renderDashboardPresetCard(preset) { function _renderDashboardPresetCard(preset) {
const borderStyle = `border-left: 3px solid ${escapeHtml(preset.color)}`;
const targetCount = (preset.targets || []).length; const targetCount = (preset.targets || []).length;
const subtitle = [ const subtitle = [
targetCount > 0 ? `${targetCount} ${t('scenes.targets_count')}` : null, targetCount > 0 ? `${targetCount} ${t('scenes.targets_count')}` : null,
].filter(Boolean).join(' \u00b7 '); ].filter(Boolean).join(' \u00b7 ');
return `<div class="dashboard-target dashboard-scene-preset dashboard-card-link" data-scene-id="${preset.id}" style="${borderStyle}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'scenes','data-scene-id','${preset.id}')}"> const pStyle = cardColorStyle(preset.id);
return `<div class="dashboard-target dashboard-scene-preset dashboard-card-link" data-scene-id="${preset.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'scenes','data-scene-id','${preset.id}')}"${pStyle ? ` style="${pStyle}"` : ''}>
<div class="dashboard-target-info"> <div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_SCENE}</span> <span class="dashboard-target-icon">${ICON_SCENE}</span>
<div> <div>
@@ -113,7 +114,7 @@ export async function openScenePresetCapture() {
document.getElementById('scene-preset-editor-id').value = ''; document.getElementById('scene-preset-editor-id').value = '';
document.getElementById('scene-preset-editor-name').value = ''; document.getElementById('scene-preset-editor-name').value = '';
document.getElementById('scene-preset-editor-description').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'; document.getElementById('scene-preset-editor-error').style.display = 'none';
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]'); const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
@@ -149,7 +150,6 @@ export async function editScenePreset(presetId) {
document.getElementById('scene-preset-editor-id').value = presetId; document.getElementById('scene-preset-editor-id').value = presetId;
document.getElementById('scene-preset-editor-name').value = preset.name; document.getElementById('scene-preset-editor-name').value = preset.name;
document.getElementById('scene-preset-editor-description').value = preset.description || ''; 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'; document.getElementById('scene-preset-editor-error').style.display = 'none';
// Hide target selector in edit mode (metadata only) // Hide target selector in edit mode (metadata only)
@@ -168,7 +168,6 @@ export async function editScenePreset(presetId) {
export async function saveScenePreset() { export async function saveScenePreset() {
const name = document.getElementById('scene-preset-editor-name').value.trim(); const name = document.getElementById('scene-preset-editor-name').value.trim();
const description = document.getElementById('scene-preset-editor-description').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'); const errorEl = document.getElementById('scene-preset-editor-error');
if (!name) { if (!name) {
@@ -182,14 +181,14 @@ export async function saveScenePreset() {
if (_editingId) { if (_editingId) {
resp = await fetchWithAuth(`/scene-presets/${_editingId}`, { resp = await fetchWithAuth(`/scene-presets/${_editingId}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ name, description, color }), body: JSON.stringify({ name, description }),
}); });
} else { } else {
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')] const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
.map(el => el.dataset.targetId); .map(el => el.dataset.targetId);
resp = await fetchWithAuth('/scene-presets', { resp = await fetchWithAuth('/scene-presets', {
method: 'POST', method: 'POST',
body: JSON.stringify({ name, description, color, target_ids }), body: JSON.stringify({ name, description, target_ids }),
}); });
} }

View File

@@ -864,7 +864,6 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
return wrapCard({ return wrapCard({
dataAttr: 'data-target-id', dataAttr: 'data-target-id',
id: target.id, id: target.id,
topButtons: `<button class="card-autostart-btn${target.auto_start ? ' active' : ''}" onclick="toggleTargetAutoStart('${target.id}', ${!target.auto_start})" title="${target.auto_start ? t('autostart.toggle.enabled') : t('autostart.toggle.disabled')}">&#x2605;</button>`,
removeOnclick: `deleteTarget('${target.id}')`, removeOnclick: `deleteTarget('${target.id}')`,
removeTitle: t('common.delete'), removeTitle: t('common.delete'),
content: ` content: `
@@ -1064,25 +1063,6 @@ export async function cloneTarget(targetId) {
} }
} }
export async function toggleTargetAutoStart(targetId, enable) {
try {
const response = await fetchWithAuth(`/picture-targets/${targetId}`, {
method: 'PUT',
body: JSON.stringify({ auto_start: enable }),
});
if (response.ok) {
showToast(t(enable ? 'autostart.toggle.enabled' : 'autostart.toggle.disabled'), 'success');
loadTargetsTab();
} else {
const error = await response.json();
showToast(error.detail || t('target.error.autostart_toggle_failed'), 'error');
}
} catch (error) {
console.error('Failed to toggle auto-start:', error);
showToast(t('target.error.autostart_toggle_failed'), 'error');
}
}
export async function deleteTarget(targetId) { export async function deleteTarget(targetId) {
const confirmed = await showConfirm(t('targets.delete.confirm')); const confirmed = await showConfirm(t('targets.delete.confirm'));
if (!confirmed) return; if (!confirmed) return;

View File

@@ -56,7 +56,8 @@ const sourcesTourSteps = [
{ selector: '[data-stream-tab="static_image"]', textKey: 'tour.src.static', position: 'bottom' }, { selector: '[data-stream-tab="static_image"]', textKey: 'tour.src.static', position: 'bottom' },
{ selector: '[data-stream-tab="processed"]', textKey: 'tour.src.processed', position: 'bottom' }, { selector: '[data-stream-tab="processed"]', textKey: 'tour.src.processed', position: 'bottom' },
{ selector: '[data-stream-tab="audio"]', textKey: 'tour.src.audio', position: 'bottom' }, { selector: '[data-stream-tab="audio"]', textKey: 'tour.src.audio', position: 'bottom' },
{ selector: '[data-stream-tab="value"]', textKey: 'tour.src.value', position: 'bottom' } { selector: '[data-stream-tab="value"]', textKey: 'tour.src.value', position: 'bottom' },
{ selector: '[data-stream-tab="sync"]', textKey: 'tour.src.sync', position: 'bottom' }
]; ];
const automationsTutorialSteps = [ const automationsTutorialSteps = [

View File

@@ -262,6 +262,7 @@
"tour.src.processed": "Processed — apply post-processing effects like blur, brightness, or color correction.", "tour.src.processed": "Processed — apply post-processing effects like blur, brightness, or color correction.",
"tour.src.audio": "Audio — analyze microphone or system audio for reactive LED effects.", "tour.src.audio": "Audio — analyze microphone or system audio for reactive LED effects.",
"tour.src.value": "Value — numeric data sources used as conditions in automations.", "tour.src.value": "Value — numeric data sources used as conditions in automations.",
"tour.src.sync": "Sync Clocks — shared timers that synchronize animations across multiple sources.",
"tour.auto.list": "Automations — automate scene activation based on time, audio, or value conditions.", "tour.auto.list": "Automations — automate scene activation based on time, audio, or value conditions.",
"tour.auto.add": "Click + to create a new automation with conditions and a scene to activate.", "tour.auto.add": "Click + to create a new automation with conditions and a scene to activate.",
"tour.auto.card": "Each card shows automation status, conditions, and quick controls to edit or toggle.", "tour.auto.card": "Each card shows automation status, conditions, and quick controls to edit or toggle.",
@@ -584,7 +585,9 @@
"automations.conditions.add": "Add Condition", "automations.conditions.add": "Add Condition",
"automations.conditions.empty": "No conditions — automation is always active when enabled", "automations.conditions.empty": "No conditions — automation is always active when enabled",
"automations.condition.always": "Always", "automations.condition.always": "Always",
"automations.condition.always.hint": "Automation activates immediately when enabled and stays active. Use this to auto-start scenes on server startup.", "automations.condition.always.hint": "Automation activates immediately when enabled and stays active.",
"automations.condition.startup": "Startup",
"automations.condition.startup.hint": "Activates when the server starts and stays active while enabled.",
"automations.condition.application": "Application", "automations.condition.application": "Application",
"automations.condition.application.apps": "Applications:", "automations.condition.application.apps": "Applications:",
"automations.condition.application.apps.hint": "Process names, one per line (e.g. firefox.exe)", "automations.condition.application.apps.hint": "Process names, one per line (e.g. firefox.exe)",
@@ -657,8 +660,6 @@
"scenes.name.placeholder": "My Scene", "scenes.name.placeholder": "My Scene",
"scenes.description": "Description:", "scenes.description": "Description:",
"scenes.description.hint": "Optional description of what this scene does", "scenes.description.hint": "Optional description of what this scene does",
"scenes.color": "Card Color:",
"scenes.color.hint": "Accent color for the scene card on the dashboard",
"scenes.targets": "Targets:", "scenes.targets": "Targets:",
"scenes.targets.hint": "Select which targets to include in this scene snapshot", "scenes.targets.hint": "Select which targets to include in this scene snapshot",
"scenes.capture": "Capture", "scenes.capture": "Capture",
@@ -680,10 +681,6 @@
"scenes.error.activate_failed": "Failed to activate scene", "scenes.error.activate_failed": "Failed to activate scene",
"scenes.error.recapture_failed": "Failed to recapture scene", "scenes.error.recapture_failed": "Failed to recapture scene",
"scenes.error.delete_failed": "Failed to delete scene", "scenes.error.delete_failed": "Failed to delete scene",
"autostart.title": "Auto-start Targets",
"autostart.toggle.enabled": "Auto-start enabled",
"autostart.toggle.disabled": "Auto-start disabled",
"autostart.goto_target": "Go to target",
"time.hours_minutes": "{h}h {m}m", "time.hours_minutes": "{h}h {m}m",
"time.minutes_seconds": "{m}m {s}s", "time.minutes_seconds": "{m}m {s}s",
"time.seconds": "{s}s", "time.seconds": "{s}s",
@@ -1110,13 +1107,11 @@
"dashboard.error.automation_toggle_failed": "Failed to toggle automation", "dashboard.error.automation_toggle_failed": "Failed to toggle automation",
"dashboard.error.start_failed": "Failed to start processing", "dashboard.error.start_failed": "Failed to start processing",
"dashboard.error.stop_failed": "Failed to stop processing", "dashboard.error.stop_failed": "Failed to stop processing",
"dashboard.error.autostart_toggle_failed": "Failed to toggle auto-start",
"dashboard.error.stop_all": "Failed to stop all targets", "dashboard.error.stop_all": "Failed to stop all targets",
"target.error.editor_open_failed": "Failed to open target editor", "target.error.editor_open_failed": "Failed to open target editor",
"target.error.start_failed": "Failed to start target", "target.error.start_failed": "Failed to start target",
"target.error.stop_failed": "Failed to stop target", "target.error.stop_failed": "Failed to stop target",
"target.error.clone_failed": "Failed to clone target", "target.error.clone_failed": "Failed to clone target",
"target.error.autostart_toggle_failed": "Failed to toggle auto-start",
"target.error.delete_failed": "Failed to delete target", "target.error.delete_failed": "Failed to delete target",
"targets.stop_all.none_running": "No targets are currently running", "targets.stop_all.none_running": "No targets are currently running",
"targets.stop_all.stopped": "Stopped {count} target(s)", "targets.stop_all.stopped": "Stopped {count} target(s)",

View File

@@ -262,6 +262,7 @@
"tour.src.processed": "Обработка — применяйте эффекты: размытие, яркость, цветокоррекция.", "tour.src.processed": "Обработка — применяйте эффекты: размытие, яркость, цветокоррекция.",
"tour.src.audio": "Аудио — анализ микрофона или системного звука для реактивных LED-эффектов.", "tour.src.audio": "Аудио — анализ микрофона или системного звука для реактивных LED-эффектов.",
"tour.src.value": "Значения — числовые источники данных для условий автоматизаций.", "tour.src.value": "Значения — числовые источники данных для условий автоматизаций.",
"tour.src.sync": "Синхро-часы — общие таймеры для синхронизации анимаций между несколькими источниками.",
"tour.auto.list": "Автоматизации — автоматизируйте активацию сцен по времени, звуку или значениям.", "tour.auto.list": "Автоматизации — автоматизируйте активацию сцен по времени, звуку или значениям.",
"tour.auto.add": "Нажмите + для создания новой автоматизации с условиями и сценой для активации.", "tour.auto.add": "Нажмите + для создания новой автоматизации с условиями и сценой для активации.",
"tour.auto.card": "Каждая карточка показывает статус автоматизации, условия и кнопки управления.", "tour.auto.card": "Каждая карточка показывает статус автоматизации, условия и кнопки управления.",
@@ -584,7 +585,9 @@
"automations.conditions.add": "Добавить условие", "automations.conditions.add": "Добавить условие",
"automations.conditions.empty": "Нет условий — автоматизация всегда активна когда включена", "automations.conditions.empty": "Нет условий — автоматизация всегда активна когда включена",
"automations.condition.always": "Всегда", "automations.condition.always": "Всегда",
"automations.condition.always.hint": "Автоматизация активируется сразу при включении и остаётся активной. Используйте для автозапуска сцен при старте сервера.", "automations.condition.always.hint": "Автоматизация активируется сразу при включении и остаётся активной.",
"automations.condition.startup": "Автозапуск",
"automations.condition.startup.hint": "Активируется при запуске сервера и остаётся активной пока включена.",
"automations.condition.application": "Приложение", "automations.condition.application": "Приложение",
"automations.condition.application.apps": "Приложения:", "automations.condition.application.apps": "Приложения:",
"automations.condition.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)", "automations.condition.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)",
@@ -657,8 +660,6 @@
"scenes.name.placeholder": "Моя сцена", "scenes.name.placeholder": "Моя сцена",
"scenes.description": "Описание:", "scenes.description": "Описание:",
"scenes.description.hint": "Необязательное описание назначения этой сцены", "scenes.description.hint": "Необязательное описание назначения этой сцены",
"scenes.color": "Цвет карточки:",
"scenes.color.hint": "Акцентный цвет для карточки сцены на панели управления",
"scenes.targets": "Цели:", "scenes.targets": "Цели:",
"scenes.targets.hint": "Выберите какие цели включить в снимок сцены", "scenes.targets.hint": "Выберите какие цели включить в снимок сцены",
"scenes.capture": "Захват", "scenes.capture": "Захват",
@@ -680,10 +681,6 @@
"scenes.error.activate_failed": "Не удалось активировать сцену", "scenes.error.activate_failed": "Не удалось активировать сцену",
"scenes.error.recapture_failed": "Не удалось перезахватить сцену", "scenes.error.recapture_failed": "Не удалось перезахватить сцену",
"scenes.error.delete_failed": "Не удалось удалить сцену", "scenes.error.delete_failed": "Не удалось удалить сцену",
"autostart.title": "Автозапуск целей",
"autostart.toggle.enabled": "Автозапуск включён",
"autostart.toggle.disabled": "Автозапуск отключён",
"autostart.goto_target": "Перейти к цели",
"time.hours_minutes": "{h}ч {m}м", "time.hours_minutes": "{h}ч {m}м",
"time.minutes_seconds": "{m}м {s}с", "time.minutes_seconds": "{m}м {s}с",
"time.seconds": "{s}с", "time.seconds": "{s}с",
@@ -1110,13 +1107,11 @@
"dashboard.error.automation_toggle_failed": "Не удалось переключить автоматизацию", "dashboard.error.automation_toggle_failed": "Не удалось переключить автоматизацию",
"dashboard.error.start_failed": "Не удалось запустить обработку", "dashboard.error.start_failed": "Не удалось запустить обработку",
"dashboard.error.stop_failed": "Не удалось остановить обработку", "dashboard.error.stop_failed": "Не удалось остановить обработку",
"dashboard.error.autostart_toggle_failed": "Не удалось переключить автозапуск",
"dashboard.error.stop_all": "Не удалось остановить все цели", "dashboard.error.stop_all": "Не удалось остановить все цели",
"target.error.editor_open_failed": "Не удалось открыть редактор цели", "target.error.editor_open_failed": "Не удалось открыть редактор цели",
"target.error.start_failed": "Не удалось запустить цель", "target.error.start_failed": "Не удалось запустить цель",
"target.error.stop_failed": "Не удалось остановить цель", "target.error.stop_failed": "Не удалось остановить цель",
"target.error.clone_failed": "Не удалось клонировать цель", "target.error.clone_failed": "Не удалось клонировать цель",
"target.error.autostart_toggle_failed": "Не удалось переключить автозапуск",
"target.error.delete_failed": "Не удалось удалить цель", "target.error.delete_failed": "Не удалось удалить цель",
"targets.stop_all.none_running": "Нет запущенных целей", "targets.stop_all.none_running": "Нет запущенных целей",
"targets.stop_all.stopped": "Остановлено целей: {count}", "targets.stop_all.stopped": "Остановлено целей: {count}",

View File

@@ -262,6 +262,7 @@
"tour.src.processed": "处理 — 应用后处理效果,如模糊、亮度或色彩校正。", "tour.src.processed": "处理 — 应用后处理效果,如模糊、亮度或色彩校正。",
"tour.src.audio": "音频 — 分析麦克风或系统音频以实现响应式 LED 效果。", "tour.src.audio": "音频 — 分析麦克风或系统音频以实现响应式 LED 效果。",
"tour.src.value": "数值 — 用于自动化条件的数字数据源。", "tour.src.value": "数值 — 用于自动化条件的数字数据源。",
"tour.src.sync": "同步时钟 — 在多个源之间同步动画的共享定时器。",
"tour.auto.list": "自动化 — 基于时间、音频或数值条件自动激活场景。", "tour.auto.list": "自动化 — 基于时间、音频或数值条件自动激活场景。",
"tour.auto.add": "点击 + 创建包含条件和要激活场景的新自动化。", "tour.auto.add": "点击 + 创建包含条件和要激活场景的新自动化。",
"tour.auto.card": "每张卡片显示自动化状态、条件和快速编辑/切换控制。", "tour.auto.card": "每张卡片显示自动化状态、条件和快速编辑/切换控制。",
@@ -584,7 +585,9 @@
"automations.conditions.add": "添加条件", "automations.conditions.add": "添加条件",
"automations.conditions.empty": "无条件 — 启用后自动化始终处于活动状态", "automations.conditions.empty": "无条件 — 启用后自动化始终处于活动状态",
"automations.condition.always": "始终", "automations.condition.always": "始终",
"automations.condition.always.hint": "自动化启用后立即激活并保持活动。用于服务器启动时自动激活场景。", "automations.condition.always.hint": "自动化启用后立即激活并保持活动。",
"automations.condition.startup": "启动",
"automations.condition.startup.hint": "服务器启动时激活,启用期间保持活动。",
"automations.condition.application": "应用程序", "automations.condition.application": "应用程序",
"automations.condition.application.apps": "应用程序:", "automations.condition.application.apps": "应用程序:",
"automations.condition.application.apps.hint": "进程名,每行一个(例如 firefox.exe", "automations.condition.application.apps.hint": "进程名,每行一个(例如 firefox.exe",
@@ -657,8 +660,6 @@
"scenes.name.placeholder": "我的场景", "scenes.name.placeholder": "我的场景",
"scenes.description": "描述:", "scenes.description": "描述:",
"scenes.description.hint": "此场景功能的可选描述", "scenes.description.hint": "此场景功能的可选描述",
"scenes.color": "卡片颜色:",
"scenes.color.hint": "仪表盘上场景卡片的强调色",
"scenes.targets": "目标:", "scenes.targets": "目标:",
"scenes.targets.hint": "选择要包含在此场景快照中的目标", "scenes.targets.hint": "选择要包含在此场景快照中的目标",
"scenes.capture": "捕获", "scenes.capture": "捕获",
@@ -680,10 +681,6 @@
"scenes.error.activate_failed": "激活场景失败", "scenes.error.activate_failed": "激活场景失败",
"scenes.error.recapture_failed": "重新捕获场景失败", "scenes.error.recapture_failed": "重新捕获场景失败",
"scenes.error.delete_failed": "删除场景失败", "scenes.error.delete_failed": "删除场景失败",
"autostart.title": "自动启动目标",
"autostart.toggle.enabled": "自动启动已启用",
"autostart.toggle.disabled": "自动启动已禁用",
"autostart.goto_target": "跳转到目标",
"time.hours_minutes": "{h}时 {m}分", "time.hours_minutes": "{h}时 {m}分",
"time.minutes_seconds": "{m}分 {s}秒", "time.minutes_seconds": "{m}分 {s}秒",
"time.seconds": "{s}秒", "time.seconds": "{s}秒",
@@ -1110,13 +1107,11 @@
"dashboard.error.automation_toggle_failed": "切换自动化失败", "dashboard.error.automation_toggle_failed": "切换自动化失败",
"dashboard.error.start_failed": "启动处理失败", "dashboard.error.start_failed": "启动处理失败",
"dashboard.error.stop_failed": "停止处理失败", "dashboard.error.stop_failed": "停止处理失败",
"dashboard.error.autostart_toggle_failed": "切换自动启动失败",
"dashboard.error.stop_all": "停止所有目标失败", "dashboard.error.stop_all": "停止所有目标失败",
"target.error.editor_open_failed": "打开目标编辑器失败", "target.error.editor_open_failed": "打开目标编辑器失败",
"target.error.start_failed": "启动目标失败", "target.error.start_failed": "启动目标失败",
"target.error.stop_failed": "停止目标失败", "target.error.stop_failed": "停止目标失败",
"target.error.clone_failed": "克隆目标失败", "target.error.clone_failed": "克隆目标失败",
"target.error.autostart_toggle_failed": "切换自动启动失败",
"target.error.delete_failed": "删除目标失败", "target.error.delete_failed": "删除目标失败",
"targets.stop_all.none_running": "当前没有运行中的目标", "targets.stop_all.none_running": "当前没有运行中的目标",
"targets.stop_all.stopped": "已停止 {count} 个目标", "targets.stop_all.stopped": "已停止 {count} 个目标",

View File

@@ -7,7 +7,7 @@
* - Navigation: network-first with offline fallback * - Navigation: network-first with offline fallback
*/ */
const CACHE_NAME = 'ledgrab-v9'; const CACHE_NAME = 'ledgrab-v10';
// Only pre-cache static assets (no auth required). // Only pre-cache static assets (no auth required).
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page. // Do NOT pre-cache '/' — it requires API key auth and would cache an error page.

View File

@@ -32,6 +32,8 @@ class Condition:
return MQTTCondition.from_dict(data) return MQTTCondition.from_dict(data)
if ct == "webhook": if ct == "webhook":
return WebhookCondition.from_dict(data) return WebhookCondition.from_dict(data)
if ct == "startup":
return StartupCondition.from_dict(data)
raise ValueError(f"Unknown condition type: {ct}") raise ValueError(f"Unknown condition type: {ct}")
@@ -177,6 +179,17 @@ class WebhookCondition(Condition):
return cls(token=data.get("token", "")) return cls(token=data.get("token", ""))
@dataclass
class StartupCondition(Condition):
"""Activate when the server starts — stays active while enabled."""
condition_type: str = "startup"
@classmethod
def from_dict(cls, data: dict) -> "StartupCondition":
return cls()
@dataclass @dataclass
class Automation: class Automation:
"""Automation that activates a scene preset based on conditions.""" """Automation that activates a scene preset based on conditions."""

View File

@@ -100,9 +100,9 @@ class KeyColorsPictureTarget(PictureTarget):
def update_fields(self, *, name=None, device_id=None, picture_source_id=None, def update_fields(self, *, name=None, device_id=None, picture_source_id=None,
settings=None, key_colors_settings=None, description=None, settings=None, key_colors_settings=None, description=None,
auto_start=None, **_kwargs) -> None: **_kwargs) -> None:
"""Apply mutable field updates for KC targets.""" """Apply mutable field updates for KC targets."""
super().update_fields(name=name, description=description, auto_start=auto_start) super().update_fields(name=name, description=description)
if picture_source_id is not None: if picture_source_id is not None:
self.picture_source_id = picture_source_id self.picture_source_id = picture_source_id
if key_colors_settings is not None: if key_colors_settings is not None:
@@ -130,7 +130,6 @@ class KeyColorsPictureTarget(PictureTarget):
picture_source_id=data.get("picture_source_id", ""), picture_source_id=data.get("picture_source_id", ""),
settings=settings, settings=settings,
description=data.get("description"), description=data.get("description"),
auto_start=data.get("auto_start", False),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
) )

View File

@@ -15,7 +15,6 @@ class PictureTarget:
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
auto_start: bool = False
def register_with_manager(self, manager) -> None: def register_with_manager(self, manager) -> None:
"""Register this target with the processor manager. Subclasses override.""" """Register this target with the processor manager. Subclasses override."""
@@ -27,14 +26,12 @@ class PictureTarget:
def update_fields(self, *, name=None, device_id=None, picture_source_id=None, def update_fields(self, *, name=None, device_id=None, picture_source_id=None,
settings=None, key_colors_settings=None, description=None, settings=None, key_colors_settings=None, description=None,
auto_start=None) -> None: **_kwargs) -> None:
"""Apply mutable field updates. Base handles common fields; subclasses handle type-specific ones.""" """Apply mutable field updates. Base handles common fields; subclasses handle type-specific ones."""
if name is not None: if name is not None:
self.name = name self.name = name
if description is not None: if description is not None:
self.description = description self.description = description
if auto_start is not None:
self.auto_start = auto_start
@property @property
def has_picture_source(self) -> bool: def has_picture_source(self) -> bool:
@@ -48,7 +45,6 @@ class PictureTarget:
"name": self.name, "name": self.name,
"target_type": self.target_type, "target_type": self.target_type,
"description": self.description, "description": self.description,
"auto_start": self.auto_start,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }

View File

@@ -105,7 +105,6 @@ class PictureTargetStore:
key_colors_settings: Optional[KeyColorsSettings] = None, key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None, description: Optional[str] = None,
picture_source_id: str = "", picture_source_id: str = "",
auto_start: bool = False,
) -> PictureTarget: ) -> PictureTarget:
"""Create a new picture target. """Create a new picture target.
@@ -138,7 +137,6 @@ class PictureTargetStore:
adaptive_fps=adaptive_fps, adaptive_fps=adaptive_fps,
protocol=protocol, protocol=protocol,
description=description, description=description,
auto_start=auto_start,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
@@ -150,7 +148,6 @@ class PictureTargetStore:
picture_source_id=picture_source_id, picture_source_id=picture_source_id,
settings=key_colors_settings or KeyColorsSettings(), settings=key_colors_settings or KeyColorsSettings(),
description=description, description=description,
auto_start=auto_start,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
@@ -178,7 +175,6 @@ class PictureTargetStore:
protocol: Optional[str] = None, protocol: Optional[str] = None,
key_colors_settings: Optional[KeyColorsSettings] = None, key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None, description: Optional[str] = None,
auto_start: Optional[bool] = None,
) -> PictureTarget: ) -> PictureTarget:
"""Update a picture target. """Update a picture target.
@@ -209,7 +205,6 @@ class PictureTargetStore:
protocol=protocol, protocol=protocol,
key_colors_settings=key_colors_settings, key_colors_settings=key_colors_settings,
description=description, description=description,
auto_start=auto_start,
) )
target.updated_at = datetime.utcnow() target.updated_at = datetime.utcnow()

View File

@@ -14,7 +14,6 @@ class TargetSnapshot:
color_strip_source_id: str = "" color_strip_source_id: str = ""
brightness_value_source_id: str = "" brightness_value_source_id: str = ""
fps: int = 30 fps: int = 30
auto_start: bool = False
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -23,7 +22,6 @@ class TargetSnapshot:
"color_strip_source_id": self.color_strip_source_id, "color_strip_source_id": self.color_strip_source_id,
"brightness_value_source_id": self.brightness_value_source_id, "brightness_value_source_id": self.brightness_value_source_id,
"fps": self.fps, "fps": self.fps,
"auto_start": self.auto_start,
} }
@classmethod @classmethod
@@ -34,7 +32,6 @@ class TargetSnapshot:
color_strip_source_id=data.get("color_strip_source_id", ""), color_strip_source_id=data.get("color_strip_source_id", ""),
brightness_value_source_id=data.get("brightness_value_source_id", ""), brightness_value_source_id=data.get("brightness_value_source_id", ""),
fps=data.get("fps", 30), fps=data.get("fps", 30),
auto_start=data.get("auto_start", False),
) )
@@ -45,7 +42,6 @@ class ScenePreset:
id: str id: str
name: str name: str
description: str = "" description: str = ""
color: str = "#4fc3f7" # accent color for the card
targets: List[TargetSnapshot] = field(default_factory=list) targets: List[TargetSnapshot] = field(default_factory=list)
order: int = 0 order: int = 0
created_at: datetime = field(default_factory=datetime.utcnow) created_at: datetime = field(default_factory=datetime.utcnow)
@@ -56,7 +52,6 @@ class ScenePreset:
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"description": self.description, "description": self.description,
"color": self.color,
"targets": [t.to_dict() for t in self.targets], "targets": [t.to_dict() for t in self.targets],
"order": self.order, "order": self.order,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
@@ -69,7 +64,6 @@ class ScenePreset:
id=data["id"], id=data["id"],
name=data["name"], name=data["name"],
description=data.get("description", ""), description=data.get("description", ""),
color=data.get("color", "#4fc3f7"),
targets=[TargetSnapshot.from_dict(t) for t in data.get("targets", [])], targets=[TargetSnapshot.from_dict(t) for t in data.get("targets", [])],
order=data.get("order", 0), order=data.get("order", 0),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),

View File

@@ -83,7 +83,6 @@ class ScenePresetStore:
preset_id: str, preset_id: str,
name: Optional[str] = None, name: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
color: Optional[str] = None,
order: Optional[int] = None, order: Optional[int] = None,
) -> ScenePreset: ) -> ScenePreset:
if preset_id not in self._presets: if preset_id not in self._presets:
@@ -98,8 +97,6 @@ class ScenePresetStore:
preset.name = name preset.name = name
if description is not None: if description is not None:
preset.description = description preset.description = description
if color is not None:
preset.color = color
if order is not None: if order is not None:
preset.order = order preset.order = order

View File

@@ -63,9 +63,9 @@ class WledPictureTarget(PictureTarget):
brightness_value_source_id=None, brightness_value_source_id=None,
fps=None, keepalive_interval=None, state_check_interval=None, fps=None, keepalive_interval=None, state_check_interval=None,
min_brightness_threshold=None, adaptive_fps=None, protocol=None, min_brightness_threshold=None, adaptive_fps=None, protocol=None,
description=None, auto_start=None, **_kwargs) -> None: description=None, **_kwargs) -> None:
"""Apply mutable field updates for WLED targets.""" """Apply mutable field updates for WLED targets."""
super().update_fields(name=name, description=description, auto_start=auto_start) super().update_fields(name=name, description=description)
if device_id is not None: if device_id is not None:
self.device_id = device_id self.device_id = device_id
if color_strip_source_id is not None: if color_strip_source_id is not None:
@@ -120,7 +120,6 @@ class WledPictureTarget(PictureTarget):
adaptive_fps=data.get("adaptive_fps", False), adaptive_fps=data.get("adaptive_fps", False),
protocol=data.get("protocol", "ddp"), protocol=data.get("protocol", "ddp"),
description=data.get("description"), description=data.get("description"),
auto_start=data.get("auto_start", False),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
) )

View File

@@ -27,15 +27,6 @@
<input type="text" id="scene-preset-editor-description"> <input type="text" id="scene-preset-editor-description">
</div> </div>
<div class="form-group">
<div class="label-row">
<label for="scene-preset-editor-color" data-i18n="scenes.color">Card Color:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="scenes.color.hint">Accent color for the scene card on the dashboard</small>
<input type="color" id="scene-preset-editor-color" value="#4fc3f7">
</div>
<div class="form-group" id="scene-target-selector-group" style="display:none"> <div class="form-group" id="scene-target-selector-group" style="display:none">
<div class="label-row"> <div class="label-row">
<label data-i18n="scenes.targets">Targets:</label> <label data-i18n="scenes.targets">Targets:</label>