diff --git a/server/src/wled_controller/api/routes/output_targets.py b/server/src/wled_controller/api/routes/output_targets.py index d83360e..b57d99f 100644 --- a/server/src/wled_controller/api/routes/output_targets.py +++ b/server/src/wled_controller/api/routes/output_targets.py @@ -315,12 +315,18 @@ async def update_target( data.adaptive_fps is not None or data.key_colors_settings is not None), css_changed=data.color_strip_source_id is not None, - device_changed=data.device_id is not None, brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed), ) except ValueError: pass + # Device change requires async stop → swap → start cycle + if data.device_id is not None: + try: + await manager.update_target_device(target_id, target.device_id) + except ValueError: + pass + fire_entity_event("output_target", "updated", target_id) return _target_to_response(target) @@ -389,7 +395,12 @@ async def start_processing( except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except RuntimeError as e: - raise HTTPException(status_code=409, detail=str(e)) + # Resolve target IDs to human-readable names in error messages + msg = str(e) + for t in target_store.get_all_targets(): + if t.id in msg: + msg = msg.replace(t.id, f"'{t.name}'") + raise HTTPException(status_code=409, detail=msg) except Exception as e: logger.error(f"Failed to start processing: {e}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index f4dcf1a..3aff4e2 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -465,13 +465,33 @@ class ProcessorManager: proc = self._get_processor(target_id) proc.update_css_source(color_strip_source_id) - def update_target_device(self, target_id: str, device_id: str): - """Update the device for a target.""" + async def update_target_device(self, target_id: str, device_id: str): + """Update the device for a target. + + If the target is currently running, performs a stop → swap → start + cycle so the new device connection is established properly. + """ proc = self._get_processor(target_id) if device_id not in self._devices: raise ValueError(f"Device {device_id} not registered") + + was_running = proc.is_running + if was_running: + logger.info( + "Hot-switching device for running target %s: stopping first", + target_id, + ) + await self.stop_processing(target_id) + proc.update_device(device_id) + if was_running: + await self.start_processing(target_id) + logger.info( + "Hot-switch complete for target %s → device %s", + target_id, device_id, + ) + def update_target_brightness_vs(self, target_id: str, vs_id: str): """Update the brightness value source for a WLED target.""" proc = self._get_processor(target_id) @@ -495,8 +515,25 @@ class ProcessorManager: and other.device_id == proc.device_id and other.is_running ): + # Stale state guard: if the task is actually finished, + # clean up and allow starting instead of blocking. + task = getattr(other, "_task", None) + if task is not None and task.done(): + logger.warning( + "Processor %s had stale is_running=True (task done) — clearing", + other_id, + ) + other._is_running = False + continue + + dev_name = proc.device_id + tgt_name = other_id + if self._device_store: + dev = self._device_store.get_device(proc.device_id) + if dev: + dev_name = dev.name raise RuntimeError( - f"Device {proc.device_id} is already being processed by target {other_id}" + f"Device '{dev_name}' is already being processed by target {tgt_name}" ) # Close cached idle client — processor creates its own connection diff --git a/server/src/wled_controller/static/css/graph-editor.css b/server/src/wled_controller/static/css/graph-editor.css index 59dc12b..f563e54 100644 --- a/server/src/wled_controller/static/css/graph-editor.css +++ b/server/src/wled_controller/static/css/graph-editor.css @@ -83,27 +83,44 @@ .graph-legend { position: absolute; top: 12px; - right: 12px; z-index: 20; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px; - padding: 10px 14px; box-shadow: 0 2px 8px var(--shadow-color); font-size: 0.8rem; display: none; - max-height: 60vh; - overflow-y: auto; + overflow: hidden; } .graph-legend.visible { display: block; } +.graph-legend-header { + display: flex; + align-items: center; + padding: 4px 10px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + cursor: grab; + user-select: none; +} + +.graph-legend-header.dragging { + cursor: grabbing; +} + .graph-legend-title { font-weight: 600; - margin-bottom: 6px; - color: var(--text-color); + font-size: 0.7rem; + color: var(--text-muted); +} + +.graph-legend-body { + padding: 6px 12px; + max-height: 55vh; + overflow-y: auto; } .graph-legend-item { @@ -112,6 +129,7 @@ gap: 8px; padding: 2px 0; color: var(--text-secondary); + white-space: nowrap; } .graph-legend-dot { diff --git a/server/src/wled_controller/static/js/core/graph-nodes.js b/server/src/wled_controller/static/js/core/graph-nodes.js index d4c007c..67e86c4 100644 --- a/server/src/wled_controller/static/js/core/graph-nodes.js +++ b/server/src/wled_controller/static/js/core/graph-nodes.js @@ -91,14 +91,20 @@ function _saveNodeColor(nodeId, color) { localStorage.setItem(_NC_KEY, JSON.stringify(map)); } -export function getNodeColor(nodeId, kind) { +/** Return custom color for a node, or null if none set. */ +export function getNodeColor(nodeId) { const map = _loadNodeColors(); - return map[nodeId] || ENTITY_COLORS[kind] || '#666'; + return map[nodeId] || null; +} + +/** Return color for a node: custom if set, else entity-type default. Used by minimap/search. */ +export function getNodeDisplayColor(nodeId, kind) { + return getNodeColor(nodeId) || ENTITY_COLORS[kind] || '#666'; } function renderNode(node, callbacks) { const { id, kind, name, subtype, x, y, width, height, running } = node; - const color = getNodeColor(id, kind); + let color = getNodeColor(id); const g = svgEl('g', { class: `graph-node${running ? ' running' : ''}`, @@ -116,21 +122,23 @@ function renderNode(node, callbacks) { }); g.appendChild(body); - // Color bar (left strip) + // Color bar (left strip) — only visible when user has set a custom color const bar = svgEl('rect', { class: 'graph-node-color-bar', x: 0, y: 0, width: 6, height, rx: 8, ry: 8, - fill: color, + fill: color || 'transparent', }); + if (!color) bar.style.display = 'none'; g.appendChild(bar); // Cover the right side rounded corners of the bar const barCover = svgEl('rect', { x: 3, y: 0, width: 4, height, - fill: color, + fill: color || 'transparent', }); + if (!color) barCover.style.display = 'none'; g.appendChild(barCover); // Clickable color bar overlay (wider hit area) @@ -165,7 +173,7 @@ function renderNode(node, callbacks) { overlay.style.cssText = `position:absolute; left:${px}px; top:${py}px; z-index:100;`; overlay.innerHTML = createColorPicker({ id: pickerId, - currentColor: color, + currentColor: color || ENTITY_COLORS[kind] || '#666', anchor: 'left', }); container.appendChild(overlay); @@ -175,6 +183,8 @@ function renderNode(node, callbacks) { color = hex; bar.setAttribute('fill', hex); barCover.setAttribute('fill', hex); + bar.style.display = ''; + barCover.style.display = ''; _saveNodeColor(id, hex); overlay.remove(); }); diff --git a/server/src/wled_controller/static/js/features/automations.js b/server/src/wled_controller/static/js/features/automations.js index 09e5b78..28c27ef 100644 --- a/server/src/wled_controller/static/js/features/automations.js +++ b/server/src/wled_controller/static/js/features/automations.js @@ -151,7 +151,7 @@ function createAutomationCard(automation, sceneMap = new Map()) { return `${t('automations.condition.application')}: ${apps} (${matchLabel})`; } if (c.condition_type === 'time_of_day') { - return `${ICON_CLOCK} ${c.start_time || '00:00'} – ${c.end_time || '23:59'}`; + return `${ICON_CLOCK} ${t('automations.condition.time_of_day')}: ${c.start_time || '00:00'} – ${c.end_time || '23:59'}`; } if (c.condition_type === 'system_idle') { const mode = c.when_idle !== false ? t('automations.condition.system_idle.when_idle') : t('automations.condition.system_idle.when_active'); @@ -207,12 +207,9 @@ function createAutomationCard(automation, sceneMap = new Map()) {