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

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