diff --git a/CLAUDE.md b/CLAUDE.md index 751f46d..dc064c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,9 +74,13 @@ ### Restart procedure -1. Stop the running Python process: `powershell -Command "Get-Process -Name python -ErrorAction SilentlyContinue | Stop-Process -Force"` -2. Start the server: `powershell -Command "Set-Location 'c:\Users\Alexei\Documents\wled-screen-controller\server'; python -m wled_controller.main"` (run in background) -3. Wait 3 seconds and check startup logs to confirm it's running +Use the PowerShell restart script — it reliably stops only the server process and starts a new detached instance: + +```bash +powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\wled-screen-controller\server\restart.ps1" +``` + +**Do NOT use** `Stop-Process -Name python` (kills unrelated Python processes like VS Code extensions) or bash background `&` jobs (get killed when the shell session ends). ## Project Structure diff --git a/custom_components/wled_screen_controller/__init__.py b/custom_components/wled_screen_controller/__init__.py index db811d8..0831567 100644 --- a/custom_components/wled_screen_controller/__init__.py +++ b/custom_components/wled_screen_controller/__init__.py @@ -30,6 +30,7 @@ PLATFORMS: list[Platform] = [ Platform.SWITCH, Platform.SENSOR, Platform.NUMBER, + Platform.LIGHT, ] diff --git a/custom_components/wled_screen_controller/coordinator.py b/custom_components/wled_screen_controller/coordinator.py index 881ae20..32a37f2 100644 --- a/custom_components/wled_screen_controller/coordinator.py +++ b/custom_components/wled_screen_controller/coordinator.py @@ -246,6 +246,23 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): resp.raise_for_status() await self.async_request_refresh() + async def set_color(self, device_id: str, color: list[int] | None) -> None: + """Set or clear the static color for a device.""" + async with self.session.put( + f"{self.server_url}/api/v1/devices/{device_id}/color", + headers={**self._auth_headers, "Content-Type": "application/json"}, + json={"color": color}, + timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + ) as resp: + if resp.status != 200: + body = await resp.text() + _LOGGER.error( + "Failed to set color for device %s: %s %s", + device_id, resp.status, body, + ) + resp.raise_for_status() + await self.async_request_refresh() + async def set_kc_brightness(self, target_id: str, brightness: int) -> None: """Set brightness for a Key Colors target (0-255 mapped to 0.0-1.0).""" brightness_float = round(brightness / 255, 4) diff --git a/custom_components/wled_screen_controller/light.py b/custom_components/wled_screen_controller/light.py new file mode 100644 index 0000000..10bdb53 --- /dev/null +++ b/custom_components/wled_screen_controller/light.py @@ -0,0 +1,151 @@ +"""Light platform for LED Screen Controller (static color + brightness).""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_RGB_COLOR, + ColorMode, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, DATA_COORDINATOR, TARGET_TYPE_KEY_COLORS +from .coordinator import WLEDScreenControllerCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up LED Screen Controller light entities.""" + data = hass.data[DOMAIN][entry.entry_id] + coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR] + + entities = [] + if coordinator.data and "targets" in coordinator.data: + devices = coordinator.data.get("devices") or {} + + for target_id, target_data in coordinator.data["targets"].items(): + info = target_data["info"] + + # Only LED targets (skip KC targets) + if info.get("target_type") == TARGET_TYPE_KEY_COLORS: + continue + + device_id = info.get("device_id", "") + if not device_id: + continue + + device_data = devices.get(device_id) + if not device_data: + continue + + capabilities = device_data.get("info", {}).get("capabilities") or [] + + # Light entity requires BOTH brightness_control AND static_color + if "brightness_control" in capabilities and "static_color" in capabilities: + entities.append( + WLEDScreenControllerLight( + coordinator, target_id, device_id, entry.entry_id, + ) + ) + + async_add_entities(entities) + + +class WLEDScreenControllerLight(CoordinatorEntity, LightEntity): + """Light entity for an LED device with brightness and static color.""" + + _attr_has_entity_name = True + _attr_color_mode = ColorMode.RGB + _attr_supported_color_modes = {ColorMode.RGB} + + def __init__( + self, + coordinator: WLEDScreenControllerCoordinator, + target_id: str, + device_id: str, + entry_id: str, + ) -> None: + """Initialize the light entity.""" + super().__init__(coordinator) + self._target_id = target_id + self._device_id = device_id + self._entry_id = entry_id + self._attr_unique_id = f"{target_id}_light" + self._attr_translation_key = "light" + + @property + def device_info(self) -> dict[str, Any]: + """Return device information.""" + return {"identifiers": {(DOMAIN, self._target_id)}} + + @property + def is_on(self) -> bool | None: + """Return True if static_color is set (not null).""" + device_data = self._get_device_data() + if not device_data: + return None + static_color = device_data.get("info", {}).get("static_color") + return static_color is not None + + @property + def brightness(self) -> int | None: + """Return the brightness (0-255).""" + device_data = self._get_device_data() + if not device_data: + return None + return device_data.get("brightness") + + @property + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the RGB color tuple.""" + device_data = self._get_device_data() + if not device_data: + return None + static_color = device_data.get("info", {}).get("static_color") + if static_color is not None and len(static_color) == 3: + return tuple(static_color) + return None + + @property + def available(self) -> bool: + """Return if entity is available.""" + if not self.coordinator.data: + return False + targets = self.coordinator.data.get("targets", {}) + devices = self.coordinator.data.get("devices", {}) + return self._target_id in targets and self._device_id in devices + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on (set static color and/or brightness).""" + if ATTR_BRIGHTNESS in kwargs: + await self.coordinator.set_brightness( + self._device_id, int(kwargs[ATTR_BRIGHTNESS]) + ) + + if ATTR_RGB_COLOR in kwargs: + r, g, b = kwargs[ATTR_RGB_COLOR] + await self.coordinator.set_color(self._device_id, [r, g, b]) + elif not self.is_on: + # Turning on without specifying color: default to white + await self.coordinator.set_color(self._device_id, [255, 255, 255]) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off (clear static color).""" + await self.coordinator.set_color(self._device_id, None) + + def _get_device_data(self) -> dict[str, Any] | None: + """Get device data from coordinator.""" + if not self.coordinator.data: + return None + return self.coordinator.data.get("devices", {}).get(self._device_id) diff --git a/custom_components/wled_screen_controller/number.py b/custom_components/wled_screen_controller/number.py index f7b7602..90b403a 100644 --- a/custom_components/wled_screen_controller/number.py +++ b/custom_components/wled_screen_controller/number.py @@ -51,7 +51,7 @@ async def async_setup_entry( continue capabilities = device_data.get("info", {}).get("capabilities") or [] - if "brightness_control" not in capabilities: + if "brightness_control" not in capabilities or "static_color" in capabilities: continue entities.append( diff --git a/custom_components/wled_screen_controller/strings.json b/custom_components/wled_screen_controller/strings.json index 59d0a22..7f8d0c6 100644 --- a/custom_components/wled_screen_controller/strings.json +++ b/custom_components/wled_screen_controller/strings.json @@ -50,6 +50,11 @@ "brightness": { "name": "Brightness" } + }, + "light": { + "light": { + "name": "Light" + } } } } diff --git a/custom_components/wled_screen_controller/translations/en.json b/custom_components/wled_screen_controller/translations/en.json index 59d0a22..7f8d0c6 100644 --- a/custom_components/wled_screen_controller/translations/en.json +++ b/custom_components/wled_screen_controller/translations/en.json @@ -50,6 +50,11 @@ "brightness": { "name": "Brightness" } + }, + "light": { + "light": { + "name": "Light" + } } } } diff --git a/custom_components/wled_screen_controller/translations/ru.json b/custom_components/wled_screen_controller/translations/ru.json index f0b8497..b00c895 100644 --- a/custom_components/wled_screen_controller/translations/ru.json +++ b/custom_components/wled_screen_controller/translations/ru.json @@ -50,6 +50,11 @@ "brightness": { "name": "Яркость" } + }, + "light": { + "light": { + "name": "Подсветка" + } } } } diff --git a/server/restart.ps1 b/server/restart.ps1 new file mode 100644 index 0000000..2b00b90 --- /dev/null +++ b/server/restart.ps1 @@ -0,0 +1,26 @@ +# Restart the WLED Screen Controller server +# Stop any running instance +$procs = Get-CimInstance Win32_Process -Filter "Name='python.exe'" | + Where-Object { $_.CommandLine -like '*wled_controller.main*' } +foreach ($p in $procs) { + Write-Host "Stopping server (PID $($p.ProcessId))..." + Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue +} +if ($procs) { Start-Sleep -Seconds 2 } + +# Start server detached +Write-Host "Starting server..." +Start-Process -FilePath python -ArgumentList '-m', 'wled_controller.main' ` + -WorkingDirectory 'c:\Users\Alexei\Documents\wled-screen-controller\server' ` + -WindowStyle Hidden + +Start-Sleep -Seconds 3 + +# Verify it's running +$check = Get-CimInstance Win32_Process -Filter "Name='python.exe'" | + Where-Object { $_.CommandLine -like '*wled_controller.main*' } +if ($check) { + Write-Host "Server started (PID $($check[0].ProcessId))" +} else { + Write-Host "WARNING: Server does not appear to be running!" +} diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 443993b..b128616 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -348,11 +348,12 @@ async def get_device_brightness( raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices") try: - if device.device_type == "adalight": - return {"brightness": device.software_brightness} provider = get_provider(device.device_type) bri = await provider.get_brightness(device.url) return {"brightness": bri} + except NotImplementedError: + # Provider has no hardware brightness; use software brightness + return {"brightness": device.software_brightness} except Exception as e: logger.error(f"Failed to get brightness for {device_id}: {e}") raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}") @@ -378,16 +379,25 @@ async def set_device_brightness( raise HTTPException(status_code=400, detail="brightness must be an integer 0-255") try: - if device.device_type == "adalight": + try: + provider = get_provider(device.device_type) + await provider.set_brightness(device.url, bri) + except NotImplementedError: + # Provider has no hardware brightness; use software brightness device.software_brightness = bri device.updated_at = __import__("datetime").datetime.utcnow() store.save() - # Update runtime state so the processing loop picks it up if device_id in manager._devices: manager._devices[device_id].software_brightness = bri - return {"brightness": bri} - provider = get_provider(device.device_type) - await provider.set_brightness(device.url, bri) + + # If device is idle with a static color, re-send it at the new brightness + ds = manager._devices.get(device_id) + if ds and ds.static_color is not None and not manager.is_device_processing(device_id): + try: + await manager.send_static_color(device_id, ds.static_color) + except Exception: + pass + return {"brightness": bri} except Exception as e: logger.error(f"Failed to set brightness for {device_id}: {e}") @@ -512,12 +522,15 @@ async def set_device_color( if ds: ds.static_color = color - # If device is idle, apply the color immediately - if color is not None and not manager.is_device_processing(device_id): + # If device is idle, apply the change immediately + if not manager.is_device_processing(device_id): try: - await manager.send_static_color(device_id, color) + if color is not None: + await manager.send_static_color(device_id, color) + else: + await manager.clear_device(device_id) except Exception as e: - logger.warning(f"Failed to apply static color immediately: {e}") + logger.warning(f"Failed to apply color change immediately: {e}") return {"color": list(color) if color else None} diff --git a/server/src/wled_controller/api/routes/profiles.py b/server/src/wled_controller/api/routes/profiles.py index 9bc6fc5..a281dbe 100644 --- a/server/src/wled_controller/api/routes/profiles.py +++ b/server/src/wled_controller/api/routes/profiles.py @@ -104,6 +104,9 @@ async def create_profile( target_ids=data.target_ids, ) + if profile.enabled: + await engine.trigger_evaluate() + return _profile_to_response(profile, engine) @@ -187,6 +190,10 @@ async def update_profile( except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) + # Re-evaluate immediately if profile is enabled (may have new conditions/targets) + if profile.enabled: + await engine.trigger_evaluate() + return _profile_to_response(profile, engine) @@ -230,6 +237,9 @@ async def enable_profile( except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) + # Evaluate immediately so targets start without waiting for the next poll cycle + await engine.trigger_evaluate() + return _profile_to_response(profile, engine) diff --git a/server/src/wled_controller/core/devices/serial_provider.py b/server/src/wled_controller/core/devices/serial_provider.py index b473fb0..23c581b 100644 --- a/server/src/wled_controller/core/devices/serial_provider.py +++ b/server/src/wled_controller/core/devices/serial_provider.py @@ -119,18 +119,28 @@ class SerialDeviceProvider(LEDDeviceProvider): async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None: """Send a solid color frame to the device. - Requires kwargs: led_count (int), baud_rate (int | None). + Accepts optional kwargs: + client: An already-connected LEDClient (e.g. cached idle client). + brightness (int): Software brightness 0-255 (default 255). + led_count (int), baud_rate (int | None). """ led_count = kwargs.get("led_count", 0) - baud_rate = kwargs.get("baud_rate") if led_count <= 0: raise ValueError(f"led_count is required to send color frame to {self.device_type} device") - client = self.create_client(url, led_count=led_count, baud_rate=baud_rate) - try: - await client.connect() - frame = np.full((led_count, 3), color, dtype=np.uint8) - await client.send_pixels(frame, brightness=255) - logger.info(f"{self.device_type} set_color: sent solid {color} to {url}") - finally: - await client.close() + brightness = kwargs.get("brightness", 255) + frame = np.full((led_count, 3), color, dtype=np.uint8) + + existing_client = kwargs.get("client") + if existing_client: + await existing_client.send_pixels(frame, brightness=brightness) + else: + baud_rate = kwargs.get("baud_rate") + client = self.create_client(url, led_count=led_count, baud_rate=baud_rate) + try: + await client.connect() + await client.send_pixels(frame, brightness=brightness) + finally: + await client.close() + + logger.info(f"{self.device_type} set_color: sent solid {color} to {url}") diff --git a/server/src/wled_controller/core/devices/wled_provider.py b/server/src/wled_controller/core/devices/wled_provider.py index a403834..b89282a 100644 --- a/server/src/wled_controller/core/devices/wled_provider.py +++ b/server/src/wled_controller/core/devices/wled_provider.py @@ -1,7 +1,7 @@ """WLED device provider — consolidates all WLED-specific dispatch logic.""" import asyncio -from typing import List, Optional +from typing import List, Optional, Tuple import httpx from zeroconf import ServiceStateChange @@ -30,7 +30,7 @@ class WLEDDeviceProvider(LEDDeviceProvider): @property def capabilities(self) -> set: - return {"brightness_control", "power_control", "standby_required"} + return {"brightness_control", "power_control", "standby_required", "static_color"} def create_client(self, url: str, **kwargs) -> LEDClient: from wled_controller.core.devices.wled_client import WLEDClient @@ -186,3 +186,16 @@ class WLEDDeviceProvider(LEDDeviceProvider): json={"on": on}, ) resp.raise_for_status() + + async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None: + """Set WLED to a solid color using the native segment color API.""" + url = url.rstrip("/") + async with httpx.AsyncClient(timeout=5.0) as http_client: + resp = await http_client.post( + f"{url}/json/state", + json={ + "on": True, + "seg": [{"col": [[color[0], color[1], color[2]]], "fx": 0}], + }, + ) + resp.raise_for_status() diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 8108e4a..b1716f4 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -597,18 +597,31 @@ class ProcessorManager: return None async def send_static_color(self, device_id: str, color: Tuple[int, int, int]) -> None: - """Send a solid color to a device via the cached idle client.""" - import numpy as np + """Send a solid color to a device via its provider.""" ds = self._devices.get(device_id) if not ds: raise ValueError(f"Device {device_id} not found") try: + provider = get_provider(ds.device_type) client = await self._get_idle_client(device_id) - frame = np.full((ds.led_count, 3), color, dtype=np.uint8) - await client.send_pixels(frame) + await provider.set_color( + ds.device_url, color, + led_count=ds.led_count, baud_rate=ds.baud_rate, client=client, + brightness=ds.software_brightness, + ) except Exception as e: logger.error(f"Failed to send static color for {device_id}: {e}") + async def clear_device(self, device_id: str) -> None: + """Clear LED output on a device (send black / power off).""" + ds = self._devices.get(device_id) + if not ds: + raise ValueError(f"Device {device_id} not found") + try: + await self._send_clear_pixels(device_id) + except Exception as e: + logger.error(f"Failed to clear device {device_id}: {e}") + async def _restore_device_idle_state(self, device_id: str) -> None: """Restore a device to its idle state when all targets stop. diff --git a/server/src/wled_controller/core/profiles/profile_engine.py b/server/src/wled_controller/core/profiles/profile_engine.py index 7ef18f6..715def1 100644 --- a/server/src/wled_controller/core/profiles/profile_engine.py +++ b/server/src/wled_controller/core/profiles/profile_engine.py @@ -21,6 +21,7 @@ class ProfileEngine: self._poll_interval = poll_interval self._detector = PlatformDetector() self._task: Optional[asyncio.Task] = None + self._eval_lock = asyncio.Lock() # Runtime state (not persisted) # profile_id → set of target_ids that THIS profile started @@ -64,6 +65,10 @@ class ProfileEngine: pass async def _evaluate_all(self) -> None: + async with self._eval_lock: + await self._evaluate_all_locked() + + async def _evaluate_all_locked(self) -> None: profiles = self._store.get_all_profiles() if not profiles: # No profiles — deactivate any stale state @@ -71,9 +76,18 @@ class ProfileEngine: await self._deactivate_profile(pid) return - # Gather platform state once per cycle - running_procs = await self._detector.get_running_processes() - topmost_proc = await self._detector.get_topmost_process() + # Only enumerate processes when at least one enabled profile has conditions + needs_detection = any( + p.enabled and len(p.conditions) > 0 + for p in profiles + ) + + if needs_detection: + running_procs = await self._detector.get_running_processes() + topmost_proc = await self._detector.get_topmost_process() + else: + running_procs = set() + topmost_proc = None active_profile_ids = set() @@ -139,6 +153,7 @@ class ProfileEngine: async def _activate_profile(self, profile: Profile) -> None: started: Set[str] = set() + failed = False for target_id in profile.target_ids: try: # Skip targets that are already running (manual or other profile) @@ -150,15 +165,17 @@ class ProfileEngine: started.add(target_id) logger.info(f"Profile '{profile.name}' started target {target_id}") except Exception as e: + failed = True logger.warning(f"Profile '{profile.name}' failed to start target {target_id}: {e}") - if started: + if started or not failed: + # Active: either we started targets, or all were already running self._active_profiles[profile.id] = started self._last_activated[profile.id] = datetime.now(timezone.utc) self._fire_event(profile.id, "activated", list(started)) - logger.info(f"Profile '{profile.name}' activated ({len(started)} targets)") + logger.info(f"Profile '{profile.name}' activated ({len(started)} targets started)") else: - logger.debug(f"Profile '{profile.name}' matched but no targets started — will retry") + logger.debug(f"Profile '{profile.name}' matched but targets failed to start — will retry") async def _deactivate_profile(self, profile_id: str) -> None: owned = self._active_profiles.pop(profile_id, set()) @@ -210,6 +227,13 @@ class ProfileEngine: result[profile.id] = self.get_profile_state(profile.id) return result + async def trigger_evaluate(self) -> None: + """Run a single evaluation cycle immediately (used after enabling a profile).""" + try: + await self._evaluate_all() + except Exception as e: + logger.error(f"Immediate profile evaluation error: {e}", exc_info=True) + async def deactivate_if_active(self, profile_id: str) -> None: """Deactivate a profile immediately (used when disabling/deleting).""" if profile_id in self._active_profiles: diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 8049496..dc115b2 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -35,10 +35,11 @@ import { updateSettingsBaudFpsHint, } from './features/devices.js'; import { - loadDashboard, startDashboardWS, stopDashboardWS, stopUptimeTimer, + loadDashboard, stopUptimeTimer, dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll, toggleDashboardSection, changeDashboardPollInterval, } from './features/dashboard.js'; +import { startEventsWS, stopEventsWS } from './core/events-ws.js'; import { startPerfPolling, stopPerfPolling, } from './features/perf-charts.js'; @@ -150,8 +151,6 @@ Object.assign(window, { // dashboard loadDashboard, - startDashboardWS, - stopDashboardWS, dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, @@ -301,6 +300,7 @@ window.addEventListener('beforeunload', () => { if (refreshInterval) { clearInterval(refreshInterval); } + stopEventsWS(); disconnectAllKCWebSockets(); }); @@ -337,6 +337,7 @@ document.addEventListener('DOMContentLoaded', async () => { loadDisplays(); loadTargetsTab(); - // Start auto-refresh + // Start global events WebSocket and auto-refresh + startEventsWS(); startAutoRefresh(); }); diff --git a/server/src/wled_controller/static/js/core/events-ws.js b/server/src/wled_controller/static/js/core/events-ws.js new file mode 100644 index 0000000..397e1cf --- /dev/null +++ b/server/src/wled_controller/static/js/core/events-ws.js @@ -0,0 +1,48 @@ +/** + * Global events WebSocket — stays connected while logged in, + * dispatches DOM custom events that feature modules can listen to. + * + * Events dispatched: server:state_change, server:profile_state_changed + */ + +import { apiKey } from './state.js'; + +let _ws = null; +let _reconnectTimer = null; + +export function startEventsWS() { + stopEventsWS(); + if (!apiKey) return; + + const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${apiKey}`; + + try { + _ws = new WebSocket(url); + _ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + document.dispatchEvent(new CustomEvent(`server:${data.type}`, { detail: data })); + } catch {} + }; + _ws.onclose = () => { + _ws = null; + _reconnectTimer = setTimeout(startEventsWS, 3000); + }; + _ws.onerror = () => {}; + } catch { + _ws = null; + } +} + +export function stopEventsWS() { + if (_reconnectTimer) { + clearTimeout(_reconnectTimer); + _reconnectTimer = null; + } + if (_ws) { + _ws.onclose = null; + _ws.close(); + _ws = null; + } +} diff --git a/server/src/wled_controller/static/js/core/state.js b/server/src/wled_controller/static/js/core/state.js index 0d50c79..37fc042 100644 --- a/server/src/wled_controller/static/js/core/state.js +++ b/server/src/wled_controller/static/js/core/state.js @@ -18,9 +18,6 @@ export function setKcTestAutoRefresh(v) { kcTestAutoRefresh = v; } export let kcTestTargetId = null; export function setKcTestTargetId(v) { kcTestTargetId = v; } -export let _dashboardWS = null; -export function set_dashboardWS(v) { _dashboardWS = v; } - export let _cachedDisplays = null; export function set_cachedDisplays(v) { _cachedDisplays = v; } diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index 09b619f..a13dcc6 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -2,7 +2,7 @@ * Dashboard — real-time target status overview. */ -import { apiKey, _dashboardWS, set_dashboardWS, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval } from '../core/state.js'; +import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js'; import { t } from '../core/i18n.js'; import { escapeHtml, handle401Error } from '../core/api.js'; @@ -239,7 +239,7 @@ function formatUptime(seconds) { return `${s}s`; } -export async function loadDashboard() { +export async function loadDashboard(forceFullRender = false) { if (_dashboardLoading) return; set_dashboardLoading(true); const container = document.getElementById('dashboard-content'); @@ -289,7 +289,7 @@ export async function loadDashboard() { const newRunningIds = running.map(t => t.id).sort().join(','); const prevRunningIds = [..._lastRunningIds].sort().join(','); const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent'); - if (hasExistingDom && newRunningIds === prevRunningIds && newRunningIds !== '') { + if (!forceFullRender && hasExistingDom && newRunningIds === prevRunningIds && newRunningIds !== '') { _updateRunningMetrics(running); set_dashboardLoading(false); return; @@ -567,32 +567,23 @@ export async function dashboardStopAll() { } } -export function startDashboardWS() { - stopDashboardWS(); - if (!apiKey) return; - const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${apiKey}`; - try { - set_dashboardWS(new WebSocket(url)); - _dashboardWS.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - if (data.type === 'state_change' || data.type === 'profile_state_changed') { - loadDashboard(); - } - } catch {} - }; - _dashboardWS.onclose = () => { set_dashboardWS(null); }; - _dashboardWS.onerror = () => { set_dashboardWS(null); }; - } catch { - set_dashboardWS(null); - } -} - export function stopUptimeTimer() { _stopUptimeTimer(); } +// React to global server events when dashboard tab is active +function _isDashboardActive() { + return (localStorage.getItem('activeTab') || 'dashboard') === 'dashboard'; +} + +document.addEventListener('server:state_change', () => { + if (_isDashboardActive()) loadDashboard(); +}); + +document.addEventListener('server:profile_state_changed', () => { + if (_isDashboardActive()) loadDashboard(true); +}); + // Re-render dashboard when language changes document.addEventListener('languageChanged', () => { if (!apiKey) return; @@ -601,10 +592,3 @@ document.addEventListener('languageChanged', () => { if (perfEl) perfEl.remove(); loadDashboard(); }); - -export function stopDashboardWS() { - if (_dashboardWS) { - _dashboardWS.close(); - set_dashboardWS(null); - } -} diff --git a/server/src/wled_controller/static/js/features/profiles.js b/server/src/wled_controller/static/js/features/profiles.js index 9b917cb..33f611e 100644 --- a/server/src/wled_controller/static/js/features/profiles.js +++ b/server/src/wled_controller/static/js/features/profiles.js @@ -13,6 +13,13 @@ const profileModal = new Modal('profile-editor-modal'); // Re-render profiles when language changes document.addEventListener('languageChanged', () => { if (apiKey) loadProfiles(); }); +// React to real-time profile state changes from global events WS +document.addEventListener('server:profile_state_changed', () => { + if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'profiles') { + loadProfiles(); + } +}); + export async function loadProfiles() { const container = document.getElementById('profiles-content'); if (!container) return; diff --git a/server/src/wled_controller/static/js/features/tabs.js b/server/src/wled_controller/static/js/features/tabs.js index 055d4b9..f34af9a 100644 --- a/server/src/wled_controller/static/js/features/tabs.js +++ b/server/src/wled_controller/static/js/features/tabs.js @@ -11,9 +11,7 @@ export function switchTab(name) { if (name === 'dashboard') { // Use window.* to avoid circular imports with feature modules if (apiKey && typeof window.loadDashboard === 'function') window.loadDashboard(); - if (apiKey && typeof window.startDashboardWS === 'function') window.startDashboardWS(); } else { - if (typeof window.stopDashboardWS === 'function') window.stopDashboardWS(); if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling(); if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer(); if (!apiKey) return;