From d05b4b78f4700ce0756aad5405a969ccfa5008a4 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 25 Feb 2026 15:08:01 +0300 Subject: [PATCH] Add auto-start targets feature with dashboard section - Add auto_start boolean field to PictureTarget model (persisted per-target) - Wire auto_start through API schemas, routes, and store - Auto-start targets on server boot in main.py lifespan - Add star toggle button on target cards (next to delete button) - Add auto-start section on dashboard between performance and profiles - Remove auto-start section from profiles tab Co-Authored-By: Claude Opus 4.6 --- .../api/routes/picture_targets.py | 5 +++ .../api/schemas/picture_targets.py | 3 ++ server/src/wled_controller/main.py | 13 ++++++++ .../src/wled_controller/static/css/cards.css | 24 ++++++++++++++ server/src/wled_controller/static/js/app.js | 3 +- .../static/js/features/dashboard.js | 32 +++++++++++++++++++ .../static/js/features/kc-targets.js | 5 ++- .../static/js/features/targets.js | 24 +++++++++++++- .../wled_controller/static/locales/en.json | 4 +++ .../wled_controller/static/locales/ru.json | 4 +++ .../storage/key_colors_picture_target.py | 5 +-- .../wled_controller/storage/picture_target.py | 7 +++- .../storage/picture_target_store.py | 5 +++ .../storage/wled_picture_target.py | 5 +-- 14 files changed, 131 insertions(+), 8 deletions(-) diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index c586e1c..aefebc1 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -101,6 +101,7 @@ def _target_to_response(target) -> PictureTargetResponse: keepalive_interval=target.keepalive_interval, state_check_interval=target.state_check_interval, description=target.description, + auto_start=target.auto_start, created_at=target.created_at, updated_at=target.updated_at, ) @@ -112,6 +113,7 @@ def _target_to_response(target) -> PictureTargetResponse: picture_source_id=target.picture_source_id, key_colors_settings=_kc_settings_to_schema(target.settings), description=target.description, + auto_start=target.auto_start, created_at=target.created_at, updated_at=target.updated_at, ) @@ -121,6 +123,7 @@ def _target_to_response(target) -> PictureTargetResponse: name=target.name, target_type=target.target_type, description=target.description, + auto_start=target.auto_start, created_at=target.created_at, updated_at=target.updated_at, ) @@ -159,6 +162,7 @@ async def create_target( picture_source_id=data.picture_source_id, key_colors_settings=kc_settings, description=data.description, + auto_start=data.auto_start, ) # Register in processor manager @@ -274,6 +278,7 @@ async def update_target( state_check_interval=data.state_check_interval, key_colors_settings=kc_settings, description=data.description, + auto_start=data.auto_start, ) # Detect KC brightness VS change (inside key_colors_settings) diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index 5605b51..758013b 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -62,6 +62,7 @@ class PictureTargetCreate(BaseModel): 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)") 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): @@ -79,6 +80,7 @@ class PictureTargetUpdate(BaseModel): 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)") 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): @@ -98,6 +100,7 @@ class PictureTargetResponse(BaseModel): picture_source_id: str = Field(default="", description="Picture source ID (key_colors)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings") 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") updated_at: datetime = Field(description="Last update timestamp") diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index db5082f..1157a63 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -147,6 +147,19 @@ async def lifespan(app: FastAPI): # Start profile engine (evaluates conditions and auto-starts/stops targets) await profile_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 # Shutdown diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 8fccee4..415e591 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -103,6 +103,30 @@ section { position: static; } +.card-autostart-btn { + background: none; + border: none; + color: #777; + font-size: 1rem; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 4px; + transition: color 0.2s, background 0.2s; +} + +.card-autostart-btn:hover { + color: var(--warning-color, #ffc107); + background: rgba(255, 193, 7, 0.1); +} + +.card-autostart-btn.active { + color: var(--warning-color, #ffc107); +} + .card-power-btn { background: none; border: none; diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 31e9b1a..267a466 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -88,7 +88,7 @@ import { showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor, startTargetProcessing, stopTargetProcessing, startTargetOverlay, stopTargetOverlay, deleteTarget, - cloneTarget, toggleLedPreview, + cloneTarget, toggleLedPreview, toggleTargetAutoStart, expandAllTargetSections, collapseAllTargetSections, } from './features/targets.js'; @@ -302,6 +302,7 @@ Object.assign(window, { deleteTarget, cloneTarget, toggleLedPreview, + toggleTargetAutoStart, // color-strip sources showCSSEditor, diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index a3ae4c8..f1ec917 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -363,6 +363,38 @@ export async function loadDashboard(forceFullRender = false) { return; } + const autoStartTargets = enriched.filter(t => t.auto_start); + if (autoStartTargets.length > 0) { + const autoStartItems = autoStartTargets.map(target => { + const isRunning = !!(target.state && target.state.processing); + const device = devicesMap[target.device_id]; + const deviceName = device ? device.name : ''; + const typeIcon = target.target_type === 'key_colors' ? '🎨' : '💡'; + const statusBadge = isRunning + ? `${t('profiles.status.active')}` + : `${t('profiles.status.inactive')}`; + return `
+
+ +
+
${escapeHtml(target.name)} ${statusBadge}
+ ${deviceName ? `
${typeIcon} ${escapeHtml(deviceName)}
` : `
${typeIcon}
`} +
+
+
+ +
+
`; + }).join(''); + + dynamicHtml += `
+ ${_sectionHeader('autostart', t('autostart.title'), autoStartTargets.length)} + ${_sectionContent('autostart', autoStartItems)} +
`; + } + if (profiles.length > 0) { const activeProfiles = profiles.filter(p => p.is_active); const inactiveProfiles = profiles.filter(p => !p.is_active); diff --git a/server/src/wled_controller/static/js/features/kc-targets.js b/server/src/wled_controller/static/js/features/kc-targets.js index 36ffcee..c6884f2 100644 --- a/server/src/wled_controller/static/js/features/kc-targets.js +++ b/server/src/wled_controller/static/js/features/kc-targets.js @@ -70,7 +70,10 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS return `
- +
+ + +
${escapeHtml(target.name)} diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 7930635..d393ecc 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -689,7 +689,10 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo return `
- +
+ + +
@@ -884,6 +887,25 @@ 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(`Failed: ${error.detail}`, 'error'); + } + } catch (error) { + console.error('Failed to toggle auto-start:', error); + showToast('Failed to toggle auto-start', 'error'); + } +} + export async function deleteTarget(targetId) { const confirmed = await showConfirm(t('targets.delete.confirm')); if (!confirmed) return; diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 56adc4e..0151649 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -552,6 +552,10 @@ "profiles.error.name_required": "Name is required", "profiles.toggle_all.start": "Start all targets", "profiles.toggle_all.stop": "Stop all targets", + "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.minutes_seconds": "{m}m {s}s", "time.seconds": "{s}s", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 8f1d6f6..aaa1320 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -552,6 +552,10 @@ "profiles.error.name_required": "Введите название", "profiles.toggle_all.start": "Запустить все цели", "profiles.toggle_all.stop": "Остановить все цели", + "autostart.title": "Автозапуск целей", + "autostart.toggle.enabled": "Автозапуск включён", + "autostart.toggle.disabled": "Автозапуск отключён", + "autostart.goto_target": "Перейти к цели", "time.hours_minutes": "{h}ч {m}м", "time.minutes_seconds": "{m}м {s}с", "time.seconds": "{s}с", diff --git a/server/src/wled_controller/storage/key_colors_picture_target.py b/server/src/wled_controller/storage/key_colors_picture_target.py index d7dd72b..003e8c7 100644 --- a/server/src/wled_controller/storage/key_colors_picture_target.py +++ b/server/src/wled_controller/storage/key_colors_picture_target.py @@ -100,9 +100,9 @@ class KeyColorsPictureTarget(PictureTarget): def update_fields(self, *, name=None, device_id=None, picture_source_id=None, settings=None, key_colors_settings=None, description=None, - **_kwargs) -> None: + auto_start=None, **_kwargs) -> None: """Apply mutable field updates for KC targets.""" - super().update_fields(name=name, description=description) + super().update_fields(name=name, description=description, auto_start=auto_start) if picture_source_id is not None: self.picture_source_id = picture_source_id if key_colors_settings is not None: @@ -130,6 +130,7 @@ class KeyColorsPictureTarget(PictureTarget): picture_source_id=data.get("picture_source_id", ""), settings=settings, description=data.get("description"), + auto_start=data.get("auto_start", False), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), ) diff --git a/server/src/wled_controller/storage/picture_target.py b/server/src/wled_controller/storage/picture_target.py index 4c24002..57cd066 100644 --- a/server/src/wled_controller/storage/picture_target.py +++ b/server/src/wled_controller/storage/picture_target.py @@ -15,6 +15,7 @@ class PictureTarget: created_at: datetime updated_at: datetime description: Optional[str] = None + auto_start: bool = False def register_with_manager(self, manager) -> None: """Register this target with the processor manager. Subclasses override.""" @@ -25,12 +26,15 @@ class PictureTarget: pass def update_fields(self, *, name=None, device_id=None, picture_source_id=None, - settings=None, key_colors_settings=None, description=None) -> None: + settings=None, key_colors_settings=None, description=None, + auto_start=None) -> None: """Apply mutable field updates. Base handles common fields; subclasses handle type-specific ones.""" if name is not None: self.name = name if description is not None: self.description = description + if auto_start is not None: + self.auto_start = auto_start @property def has_picture_source(self) -> bool: @@ -44,6 +48,7 @@ class PictureTarget: "name": self.name, "target_type": self.target_type, "description": self.description, + "auto_start": self.auto_start, "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), } diff --git a/server/src/wled_controller/storage/picture_target_store.py b/server/src/wled_controller/storage/picture_target_store.py index 249aee3..1708e3c 100644 --- a/server/src/wled_controller/storage/picture_target_store.py +++ b/server/src/wled_controller/storage/picture_target_store.py @@ -109,6 +109,7 @@ class PictureTargetStore: key_colors_settings: Optional[KeyColorsSettings] = None, description: Optional[str] = None, picture_source_id: str = "", + auto_start: bool = False, ) -> PictureTarget: """Create a new picture target. @@ -138,6 +139,7 @@ class PictureTargetStore: keepalive_interval=keepalive_interval, state_check_interval=state_check_interval, description=description, + auto_start=auto_start, created_at=now, updated_at=now, ) @@ -149,6 +151,7 @@ class PictureTargetStore: picture_source_id=picture_source_id, settings=key_colors_settings or KeyColorsSettings(), description=description, + auto_start=auto_start, created_at=now, updated_at=now, ) @@ -173,6 +176,7 @@ class PictureTargetStore: state_check_interval: Optional[int] = None, key_colors_settings: Optional[KeyColorsSettings] = None, description: Optional[str] = None, + auto_start: Optional[bool] = None, ) -> PictureTarget: """Update a picture target. @@ -200,6 +204,7 @@ class PictureTargetStore: state_check_interval=state_check_interval, key_colors_settings=key_colors_settings, description=description, + auto_start=auto_start, ) target.updated_at = datetime.utcnow() diff --git a/server/src/wled_controller/storage/wled_picture_target.py b/server/src/wled_controller/storage/wled_picture_target.py index 64d1c1a..40d30f0 100644 --- a/server/src/wled_controller/storage/wled_picture_target.py +++ b/server/src/wled_controller/storage/wled_picture_target.py @@ -54,9 +54,9 @@ class WledPictureTarget(PictureTarget): def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None, brightness_value_source_id=None, fps=None, keepalive_interval=None, state_check_interval=None, - description=None, **_kwargs) -> None: + description=None, auto_start=None, **_kwargs) -> None: """Apply mutable field updates for WLED targets.""" - super().update_fields(name=name, description=description) + super().update_fields(name=name, description=description, auto_start=auto_start) if device_id is not None: self.device_id = device_id if color_strip_source_id is not None: @@ -109,6 +109,7 @@ class WledPictureTarget(PictureTarget): keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)), state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL), description=data.get("description"), + auto_start=data.get("auto_start", False), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), )