Add static color support, HAOS light entity, and real-time profile updates
- Add static_color capability to WLED and serial providers with native set_color() dispatch (WLED uses JSON API, serial uses idle client) - Encapsulate device-specific logic in providers instead of device_type checks in ProcessorManager and API routes - Add HAOS light entity for devices with brightness_control + static_color (Adalight/AmbiLED get light entity, WLED keeps number entity) - Fix serial device brightness and turn-off: pass software_brightness through provider chain, clear device on color=null, re-send static color after brightness change - Add global events WebSocket (events-ws.js) replacing per-tab WS, enabling real-time profile state updates on both dashboard and profiles tabs - Fix profile activation: mark active when all targets already running, add asyncio.Lock to prevent concurrent evaluation races, skip process enumeration when no profile has conditions, trigger immediate evaluation on enable/create/update for instant target startup - Add reliable server restart script (restart.ps1) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
10
CLAUDE.md
10
CLAUDE.md
@@ -74,9 +74,13 @@
|
|||||||
|
|
||||||
### Restart procedure
|
### Restart procedure
|
||||||
|
|
||||||
1. Stop the running Python process: `powershell -Command "Get-Process -Name python -ErrorAction SilentlyContinue | Stop-Process -Force"`
|
Use the PowerShell restart script — it reliably stops only the server process and starts a new detached instance:
|
||||||
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
|
```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
|
## Project Structure
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ PLATFORMS: list[Platform] = [
|
|||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
Platform.NUMBER,
|
Platform.NUMBER,
|
||||||
|
Platform.LIGHT,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -246,6 +246,23 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
await self.async_request_refresh()
|
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:
|
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)."""
|
"""Set brightness for a Key Colors target (0-255 mapped to 0.0-1.0)."""
|
||||||
brightness_float = round(brightness / 255, 4)
|
brightness_float = round(brightness / 255, 4)
|
||||||
|
|||||||
151
custom_components/wled_screen_controller/light.py
Normal file
151
custom_components/wled_screen_controller/light.py
Normal file
@@ -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)
|
||||||
@@ -51,7 +51,7 @@ async def async_setup_entry(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
capabilities = device_data.get("info", {}).get("capabilities") or []
|
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
|
continue
|
||||||
|
|
||||||
entities.append(
|
entities.append(
|
||||||
|
|||||||
@@ -50,6 +50,11 @@
|
|||||||
"brightness": {
|
"brightness": {
|
||||||
"name": "Brightness"
|
"name": "Brightness"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"light": {
|
||||||
|
"name": "Light"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,11 @@
|
|||||||
"brightness": {
|
"brightness": {
|
||||||
"name": "Brightness"
|
"name": "Brightness"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"light": {
|
||||||
|
"name": "Light"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,11 @@
|
|||||||
"brightness": {
|
"brightness": {
|
||||||
"name": "Яркость"
|
"name": "Яркость"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"light": {
|
||||||
|
"name": "Подсветка"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
server/restart.ps1
Normal file
26
server/restart.ps1
Normal file
@@ -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!"
|
||||||
|
}
|
||||||
@@ -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")
|
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if device.device_type == "adalight":
|
|
||||||
return {"brightness": device.software_brightness}
|
|
||||||
provider = get_provider(device.device_type)
|
provider = get_provider(device.device_type)
|
||||||
bri = await provider.get_brightness(device.url)
|
bri = await provider.get_brightness(device.url)
|
||||||
return {"brightness": bri}
|
return {"brightness": bri}
|
||||||
|
except NotImplementedError:
|
||||||
|
# Provider has no hardware brightness; use software brightness
|
||||||
|
return {"brightness": device.software_brightness}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get brightness for {device_id}: {e}")
|
logger.error(f"Failed to get brightness for {device_id}: {e}")
|
||||||
raise HTTPException(status_code=502, detail=f"Failed to reach device: {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")
|
raise HTTPException(status_code=400, detail="brightness must be an integer 0-255")
|
||||||
|
|
||||||
try:
|
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.software_brightness = bri
|
||||||
device.updated_at = __import__("datetime").datetime.utcnow()
|
device.updated_at = __import__("datetime").datetime.utcnow()
|
||||||
store.save()
|
store.save()
|
||||||
# Update runtime state so the processing loop picks it up
|
|
||||||
if device_id in manager._devices:
|
if device_id in manager._devices:
|
||||||
manager._devices[device_id].software_brightness = bri
|
manager._devices[device_id].software_brightness = bri
|
||||||
return {"brightness": bri}
|
|
||||||
provider = get_provider(device.device_type)
|
# If device is idle with a static color, re-send it at the new brightness
|
||||||
await provider.set_brightness(device.url, bri)
|
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}
|
return {"brightness": bri}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to set brightness for {device_id}: {e}")
|
logger.error(f"Failed to set brightness for {device_id}: {e}")
|
||||||
@@ -512,12 +522,15 @@ async def set_device_color(
|
|||||||
if ds:
|
if ds:
|
||||||
ds.static_color = color
|
ds.static_color = color
|
||||||
|
|
||||||
# If device is idle, apply the color immediately
|
# If device is idle, apply the change immediately
|
||||||
if color is not None and not manager.is_device_processing(device_id):
|
if not manager.is_device_processing(device_id):
|
||||||
try:
|
try:
|
||||||
|
if color is not None:
|
||||||
await manager.send_static_color(device_id, color)
|
await manager.send_static_color(device_id, color)
|
||||||
|
else:
|
||||||
|
await manager.clear_device(device_id)
|
||||||
except Exception as e:
|
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}
|
return {"color": list(color) if color else None}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ async def create_profile(
|
|||||||
target_ids=data.target_ids,
|
target_ids=data.target_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if profile.enabled:
|
||||||
|
await engine.trigger_evaluate()
|
||||||
|
|
||||||
return _profile_to_response(profile, engine)
|
return _profile_to_response(profile, engine)
|
||||||
|
|
||||||
|
|
||||||
@@ -187,6 +190,10 @@ async def update_profile(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(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)
|
return _profile_to_response(profile, engine)
|
||||||
|
|
||||||
|
|
||||||
@@ -230,6 +237,9 @@ async def enable_profile(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(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)
|
return _profile_to_response(profile, engine)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -119,18 +119,28 @@ class SerialDeviceProvider(LEDDeviceProvider):
|
|||||||
async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None:
|
async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None:
|
||||||
"""Send a solid color frame to the device.
|
"""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)
|
led_count = kwargs.get("led_count", 0)
|
||||||
baud_rate = kwargs.get("baud_rate")
|
|
||||||
if led_count <= 0:
|
if led_count <= 0:
|
||||||
raise ValueError(f"led_count is required to send color frame to {self.device_type} device")
|
raise ValueError(f"led_count is required to send color frame to {self.device_type} device")
|
||||||
|
|
||||||
|
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)
|
client = self.create_client(url, led_count=led_count, baud_rate=baud_rate)
|
||||||
try:
|
try:
|
||||||
await client.connect()
|
await client.connect()
|
||||||
frame = np.full((led_count, 3), color, dtype=np.uint8)
|
await client.send_pixels(frame, brightness=brightness)
|
||||||
await client.send_pixels(frame, brightness=255)
|
|
||||||
logger.info(f"{self.device_type} set_color: sent solid {color} to {url}")
|
|
||||||
finally:
|
finally:
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
|
logger.info(f"{self.device_type} set_color: sent solid {color} to {url}")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""WLED device provider — consolidates all WLED-specific dispatch logic."""
|
"""WLED device provider — consolidates all WLED-specific dispatch logic."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import List, Optional
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from zeroconf import ServiceStateChange
|
from zeroconf import ServiceStateChange
|
||||||
@@ -30,7 +30,7 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def capabilities(self) -> set:
|
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:
|
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||||
from wled_controller.core.devices.wled_client import WLEDClient
|
from wled_controller.core.devices.wled_client import WLEDClient
|
||||||
@@ -186,3 +186,16 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
|||||||
json={"on": on},
|
json={"on": on},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
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()
|
||||||
|
|||||||
@@ -597,18 +597,31 @@ class ProcessorManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def send_static_color(self, device_id: str, color: Tuple[int, int, int]) -> 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."""
|
"""Send a solid color to a device via its provider."""
|
||||||
import numpy as np
|
|
||||||
ds = self._devices.get(device_id)
|
ds = self._devices.get(device_id)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Device {device_id} not found")
|
raise ValueError(f"Device {device_id} not found")
|
||||||
try:
|
try:
|
||||||
|
provider = get_provider(ds.device_type)
|
||||||
client = await self._get_idle_client(device_id)
|
client = await self._get_idle_client(device_id)
|
||||||
frame = np.full((ds.led_count, 3), color, dtype=np.uint8)
|
await provider.set_color(
|
||||||
await client.send_pixels(frame)
|
ds.device_url, color,
|
||||||
|
led_count=ds.led_count, baud_rate=ds.baud_rate, client=client,
|
||||||
|
brightness=ds.software_brightness,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send static color for {device_id}: {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:
|
async def _restore_device_idle_state(self, device_id: str) -> None:
|
||||||
"""Restore a device to its idle state when all targets stop.
|
"""Restore a device to its idle state when all targets stop.
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class ProfileEngine:
|
|||||||
self._poll_interval = poll_interval
|
self._poll_interval = poll_interval
|
||||||
self._detector = PlatformDetector()
|
self._detector = PlatformDetector()
|
||||||
self._task: Optional[asyncio.Task] = None
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
self._eval_lock = asyncio.Lock()
|
||||||
|
|
||||||
# Runtime state (not persisted)
|
# Runtime state (not persisted)
|
||||||
# profile_id → set of target_ids that THIS profile started
|
# profile_id → set of target_ids that THIS profile started
|
||||||
@@ -64,6 +65,10 @@ class ProfileEngine:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
async def _evaluate_all(self) -> None:
|
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()
|
profiles = self._store.get_all_profiles()
|
||||||
if not profiles:
|
if not profiles:
|
||||||
# No profiles — deactivate any stale state
|
# No profiles — deactivate any stale state
|
||||||
@@ -71,9 +76,18 @@ class ProfileEngine:
|
|||||||
await self._deactivate_profile(pid)
|
await self._deactivate_profile(pid)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Gather platform state once per cycle
|
# 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()
|
running_procs = await self._detector.get_running_processes()
|
||||||
topmost_proc = await self._detector.get_topmost_process()
|
topmost_proc = await self._detector.get_topmost_process()
|
||||||
|
else:
|
||||||
|
running_procs = set()
|
||||||
|
topmost_proc = None
|
||||||
|
|
||||||
active_profile_ids = set()
|
active_profile_ids = set()
|
||||||
|
|
||||||
@@ -139,6 +153,7 @@ class ProfileEngine:
|
|||||||
|
|
||||||
async def _activate_profile(self, profile: Profile) -> None:
|
async def _activate_profile(self, profile: Profile) -> None:
|
||||||
started: Set[str] = set()
|
started: Set[str] = set()
|
||||||
|
failed = False
|
||||||
for target_id in profile.target_ids:
|
for target_id in profile.target_ids:
|
||||||
try:
|
try:
|
||||||
# Skip targets that are already running (manual or other profile)
|
# Skip targets that are already running (manual or other profile)
|
||||||
@@ -150,15 +165,17 @@ class ProfileEngine:
|
|||||||
started.add(target_id)
|
started.add(target_id)
|
||||||
logger.info(f"Profile '{profile.name}' started target {target_id}")
|
logger.info(f"Profile '{profile.name}' started target {target_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failed = True
|
||||||
logger.warning(f"Profile '{profile.name}' failed to start target {target_id}: {e}")
|
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._active_profiles[profile.id] = started
|
||||||
self._last_activated[profile.id] = datetime.now(timezone.utc)
|
self._last_activated[profile.id] = datetime.now(timezone.utc)
|
||||||
self._fire_event(profile.id, "activated", list(started))
|
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:
|
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:
|
async def _deactivate_profile(self, profile_id: str) -> None:
|
||||||
owned = self._active_profiles.pop(profile_id, set())
|
owned = self._active_profiles.pop(profile_id, set())
|
||||||
@@ -210,6 +227,13 @@ class ProfileEngine:
|
|||||||
result[profile.id] = self.get_profile_state(profile.id)
|
result[profile.id] = self.get_profile_state(profile.id)
|
||||||
return result
|
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:
|
async def deactivate_if_active(self, profile_id: str) -> None:
|
||||||
"""Deactivate a profile immediately (used when disabling/deleting)."""
|
"""Deactivate a profile immediately (used when disabling/deleting)."""
|
||||||
if profile_id in self._active_profiles:
|
if profile_id in self._active_profiles:
|
||||||
|
|||||||
@@ -35,10 +35,11 @@ import {
|
|||||||
updateSettingsBaudFpsHint,
|
updateSettingsBaudFpsHint,
|
||||||
} from './features/devices.js';
|
} from './features/devices.js';
|
||||||
import {
|
import {
|
||||||
loadDashboard, startDashboardWS, stopDashboardWS, stopUptimeTimer,
|
loadDashboard, stopUptimeTimer,
|
||||||
dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll,
|
dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll,
|
||||||
toggleDashboardSection, changeDashboardPollInterval,
|
toggleDashboardSection, changeDashboardPollInterval,
|
||||||
} from './features/dashboard.js';
|
} from './features/dashboard.js';
|
||||||
|
import { startEventsWS, stopEventsWS } from './core/events-ws.js';
|
||||||
import {
|
import {
|
||||||
startPerfPolling, stopPerfPolling,
|
startPerfPolling, stopPerfPolling,
|
||||||
} from './features/perf-charts.js';
|
} from './features/perf-charts.js';
|
||||||
@@ -150,8 +151,6 @@ Object.assign(window, {
|
|||||||
|
|
||||||
// dashboard
|
// dashboard
|
||||||
loadDashboard,
|
loadDashboard,
|
||||||
startDashboardWS,
|
|
||||||
stopDashboardWS,
|
|
||||||
dashboardToggleProfile,
|
dashboardToggleProfile,
|
||||||
dashboardStartTarget,
|
dashboardStartTarget,
|
||||||
dashboardStopTarget,
|
dashboardStopTarget,
|
||||||
@@ -301,6 +300,7 @@ window.addEventListener('beforeunload', () => {
|
|||||||
if (refreshInterval) {
|
if (refreshInterval) {
|
||||||
clearInterval(refreshInterval);
|
clearInterval(refreshInterval);
|
||||||
}
|
}
|
||||||
|
stopEventsWS();
|
||||||
disconnectAllKCWebSockets();
|
disconnectAllKCWebSockets();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -337,6 +337,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
loadDisplays();
|
loadDisplays();
|
||||||
loadTargetsTab();
|
loadTargetsTab();
|
||||||
|
|
||||||
// Start auto-refresh
|
// Start global events WebSocket and auto-refresh
|
||||||
|
startEventsWS();
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
});
|
});
|
||||||
|
|||||||
48
server/src/wled_controller/static/js/core/events-ws.js
Normal file
48
server/src/wled_controller/static/js/core/events-ws.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,9 +18,6 @@ export function setKcTestAutoRefresh(v) { kcTestAutoRefresh = v; }
|
|||||||
export let kcTestTargetId = null;
|
export let kcTestTargetId = null;
|
||||||
export function setKcTestTargetId(v) { kcTestTargetId = v; }
|
export function setKcTestTargetId(v) { kcTestTargetId = v; }
|
||||||
|
|
||||||
export let _dashboardWS = null;
|
|
||||||
export function set_dashboardWS(v) { _dashboardWS = v; }
|
|
||||||
|
|
||||||
export let _cachedDisplays = null;
|
export let _cachedDisplays = null;
|
||||||
export function set_cachedDisplays(v) { _cachedDisplays = v; }
|
export function set_cachedDisplays(v) { _cachedDisplays = v; }
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Dashboard — real-time target status overview.
|
* 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 { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js';
|
||||||
import { t } from '../core/i18n.js';
|
import { t } from '../core/i18n.js';
|
||||||
import { escapeHtml, handle401Error } from '../core/api.js';
|
import { escapeHtml, handle401Error } from '../core/api.js';
|
||||||
@@ -239,7 +239,7 @@ function formatUptime(seconds) {
|
|||||||
return `${s}s`;
|
return `${s}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadDashboard() {
|
export async function loadDashboard(forceFullRender = false) {
|
||||||
if (_dashboardLoading) return;
|
if (_dashboardLoading) return;
|
||||||
set_dashboardLoading(true);
|
set_dashboardLoading(true);
|
||||||
const container = document.getElementById('dashboard-content');
|
const container = document.getElementById('dashboard-content');
|
||||||
@@ -289,7 +289,7 @@ export async function loadDashboard() {
|
|||||||
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(',');
|
||||||
const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent');
|
const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent');
|
||||||
if (hasExistingDom && newRunningIds === prevRunningIds && newRunningIds !== '') {
|
if (!forceFullRender && hasExistingDom && newRunningIds === prevRunningIds && newRunningIds !== '') {
|
||||||
_updateRunningMetrics(running);
|
_updateRunningMetrics(running);
|
||||||
set_dashboardLoading(false);
|
set_dashboardLoading(false);
|
||||||
return;
|
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() {
|
export function stopUptimeTimer() {
|
||||||
_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
|
// Re-render dashboard when language changes
|
||||||
document.addEventListener('languageChanged', () => {
|
document.addEventListener('languageChanged', () => {
|
||||||
if (!apiKey) return;
|
if (!apiKey) return;
|
||||||
@@ -601,10 +592,3 @@ document.addEventListener('languageChanged', () => {
|
|||||||
if (perfEl) perfEl.remove();
|
if (perfEl) perfEl.remove();
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
});
|
});
|
||||||
|
|
||||||
export function stopDashboardWS() {
|
|
||||||
if (_dashboardWS) {
|
|
||||||
_dashboardWS.close();
|
|
||||||
set_dashboardWS(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ const profileModal = new Modal('profile-editor-modal');
|
|||||||
// Re-render profiles when language changes
|
// Re-render profiles when language changes
|
||||||
document.addEventListener('languageChanged', () => { if (apiKey) loadProfiles(); });
|
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() {
|
export async function loadProfiles() {
|
||||||
const container = document.getElementById('profiles-content');
|
const container = document.getElementById('profiles-content');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ export function switchTab(name) {
|
|||||||
if (name === 'dashboard') {
|
if (name === 'dashboard') {
|
||||||
// Use window.* to avoid circular imports with feature modules
|
// Use window.* to avoid circular imports with feature modules
|
||||||
if (apiKey && typeof window.loadDashboard === 'function') window.loadDashboard();
|
if (apiKey && typeof window.loadDashboard === 'function') window.loadDashboard();
|
||||||
if (apiKey && typeof window.startDashboardWS === 'function') window.startDashboardWS();
|
|
||||||
} else {
|
} else {
|
||||||
if (typeof window.stopDashboardWS === 'function') window.stopDashboardWS();
|
|
||||||
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
|
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
|
||||||
if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
|
if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
|
||||||
if (!apiKey) return;
|
if (!apiKey) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user