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:
2026-02-19 14:23:47 +03:00
parent 6388e0defa
commit bef28ece5c
21 changed files with 410 additions and 78 deletions

View File

@@ -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}

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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()

View File

@@ -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.

View File

@@ -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:

View File

@@ -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();
});

View 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;
}
}

View File

@@ -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; }

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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;