Initial commit: WLED Screen Controller with FastAPI server and Home Assistant integration
Some checks failed
Validate / validate (push) Failing after 1m6s
Some checks failed
Validate / validate (push) Failing after 1m6s
This is a complete WLED ambient lighting controller that captures screen border pixels and sends them to WLED devices for immersive ambient lighting effects. ## Server Features: - FastAPI-based REST API with 17+ endpoints - Real-time screen capture with multi-monitor support - Advanced LED calibration system with visual GUI - API key authentication with labeled tokens - Per-device brightness control (0-100%) - Configurable FPS (1-60), border width, and color correction - Persistent device storage (JSON-based) - Comprehensive Web UI with dark/light themes - Docker support with docker-compose - Windows monitor name detection via WMI (shows "LG ULTRAWIDE" etc.) ## Web UI Features: - Device management (add, configure, remove WLED devices) - Real-time status monitoring with FPS metrics - Settings modal for device configuration - Visual calibration GUI with edge testing - Brightness slider per device - Display selection with friendly monitor names - Token-based authentication with login/logout - Responsive button layout ## Calibration System: - Support for any LED strip layout (clockwise/counterclockwise) - 4 starting position options (corners) - Per-edge LED count configuration - Visual preview with starting position indicator - Test buttons to light up individual edges - Smart LED ordering based on start position and direction ## Home Assistant Integration: - Custom HACS integration - Switch entities for processing control - Sensor entities for status and FPS - Select entities for display selection - Config flow for easy setup - Auto-discovery of devices from server ## Technical Stack: - Python 3.11+ - FastAPI + uvicorn - mss (screen capture) - httpx (async WLED client) - Pydantic (validation) - WMI (Windows monitor detection) - Structlog (logging) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
780
server/src/wled_controller/static/app.js
Normal file
780
server/src/wled_controller/static/app.js
Normal file
@@ -0,0 +1,780 @@
|
||||
const API_BASE = '/api/v1';
|
||||
let refreshInterval = null;
|
||||
let apiKey = null;
|
||||
|
||||
// Initialize app
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Load API key from localStorage
|
||||
apiKey = localStorage.getItem('wled_api_key');
|
||||
|
||||
// Setup form handler
|
||||
document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
|
||||
|
||||
// Show modal if no API key is stored
|
||||
if (!apiKey) {
|
||||
// Wait for modal functions to be defined
|
||||
setTimeout(() => {
|
||||
if (typeof showApiKeyModal === 'function') {
|
||||
showApiKeyModal('Welcome! Please login with your API key to get started.', true);
|
||||
}
|
||||
}, 100);
|
||||
return; // Don't load data yet
|
||||
}
|
||||
|
||||
// User is logged in, load data
|
||||
loadServerInfo();
|
||||
loadDisplays();
|
||||
loadDevices();
|
||||
|
||||
// Start auto-refresh
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
// Helper function to add auth header if needed
|
||||
function getHeaders() {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (apiKey) {
|
||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Handle 401 errors by showing login modal
|
||||
function handle401Error() {
|
||||
// Clear invalid API key
|
||||
localStorage.removeItem('wled_api_key');
|
||||
apiKey = null;
|
||||
|
||||
if (typeof updateAuthUI === 'function') {
|
||||
updateAuthUI();
|
||||
}
|
||||
|
||||
if (typeof showApiKeyModal === 'function') {
|
||||
showApiKeyModal('Your session has expired or the API key is invalid. Please login again.', true);
|
||||
} else {
|
||||
showToast('Authentication failed. Please reload the page and login.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Configure API key
|
||||
function configureApiKey() {
|
||||
const currentKey = localStorage.getItem('wled_api_key');
|
||||
const message = currentKey
|
||||
? 'Current API key is set. Enter new key to update or leave blank to remove:'
|
||||
: 'Enter your API key:';
|
||||
|
||||
const key = prompt(message);
|
||||
|
||||
if (key === null) {
|
||||
return; // Cancelled
|
||||
}
|
||||
|
||||
if (key === '') {
|
||||
localStorage.removeItem('wled_api_key');
|
||||
apiKey = null;
|
||||
document.getElementById('api-key-btn').style.display = 'none';
|
||||
showToast('API key removed', 'info');
|
||||
} else {
|
||||
localStorage.setItem('wled_api_key', key);
|
||||
apiKey = key;
|
||||
document.getElementById('api-key-btn').style.display = 'inline-block';
|
||||
showToast('API key updated', 'success');
|
||||
}
|
||||
|
||||
// Reload data with new key
|
||||
loadServerInfo();
|
||||
loadDisplays();
|
||||
loadDevices();
|
||||
}
|
||||
|
||||
// Server info
|
||||
async function loadServerInfo() {
|
||||
try {
|
||||
const response = await fetch('/health');
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('server-version').textContent = `Version: ${data.version}`;
|
||||
document.getElementById('server-status').textContent = '●';
|
||||
document.getElementById('server-status').className = 'status-badge online';
|
||||
} catch (error) {
|
||||
console.error('Failed to load server info:', error);
|
||||
document.getElementById('server-status').className = 'status-badge offline';
|
||||
showToast('Server offline', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Load displays
|
||||
async function loadDisplays() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/config/displays`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const container = document.getElementById('displays-list');
|
||||
|
||||
if (!data.displays || data.displays.length === 0) {
|
||||
container.innerHTML = '<div class="loading">No displays available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = data.displays.map(display => `
|
||||
<div class="display-card">
|
||||
<div class="display-index">${display.name}</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Resolution:</span>
|
||||
<span class="info-value">${display.width} × ${display.height}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Position:</span>
|
||||
<span class="info-value">${display.x}, ${display.y}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to load displays:', error);
|
||||
document.getElementById('displays-list').innerHTML =
|
||||
'<div class="loading">Failed to load displays</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Load devices
|
||||
async function loadDevices() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const devices = data.devices || [];
|
||||
|
||||
const container = document.getElementById('devices-list');
|
||||
|
||||
if (!devices || devices.length === 0) {
|
||||
container.innerHTML = '<div class="loading">No devices attached</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch state for each device
|
||||
const devicesWithState = await Promise.all(
|
||||
devices.map(async (device) => {
|
||||
try {
|
||||
const stateResponse = await fetch(`${API_BASE}/devices/${device.id}/state`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const state = await stateResponse.json();
|
||||
|
||||
const metricsResponse = await fetch(`${API_BASE}/devices/${device.id}/metrics`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const metrics = await metricsResponse.json();
|
||||
|
||||
return { ...device, state, metrics };
|
||||
} catch (error) {
|
||||
console.error(`Failed to load state for device ${device.id}:`, error);
|
||||
return device;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
container.innerHTML = devicesWithState.map(device => createDeviceCard(device)).join('');
|
||||
|
||||
// Attach event listeners
|
||||
devicesWithState.forEach(device => {
|
||||
attachDeviceListeners(device.id);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load devices:', error);
|
||||
document.getElementById('devices-list').innerHTML =
|
||||
'<div class="loading">Failed to load devices</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function createDeviceCard(device) {
|
||||
const state = device.state || {};
|
||||
const metrics = device.metrics || {};
|
||||
const settings = device.settings || {};
|
||||
|
||||
const isProcessing = state.processing || false;
|
||||
const status = isProcessing ? 'processing' : 'idle';
|
||||
|
||||
return `
|
||||
<div class="card" data-device-id="${device.id}">
|
||||
<div class="card-header">
|
||||
<div class="card-title">${device.name || device.id}</div>
|
||||
<span class="badge ${status}">${status.toUpperCase()}</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="info-row">
|
||||
<span class="info-label">URL:</span>
|
||||
<span class="info-value">${device.url || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">LED Count:</span>
|
||||
<span class="info-value">${device.led_count || 0}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Display:</span>
|
||||
<span class="info-value">Display ${settings.display_index !== undefined ? settings.display_index : 0}</span>
|
||||
</div>
|
||||
${isProcessing ? `
|
||||
<div class="metrics-grid">
|
||||
<div class="metric">
|
||||
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
|
||||
<div class="metric-label">Actual FPS</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">${state.fps_target || 0}</div>
|
||||
<div class="metric-label">Target FPS</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">${metrics.frames_processed || 0}</div>
|
||||
<div class="metric-label">Frames</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">${metrics.errors_count || 0}</div>
|
||||
<div class="metric-label">Errors</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
${isProcessing ? `
|
||||
<button class="btn btn-danger" onclick="stopProcessing('${device.id}')">
|
||||
Stop Processing
|
||||
</button>
|
||||
` : `
|
||||
<button class="btn btn-primary" onclick="startProcessing('${device.id}')">
|
||||
Start Processing
|
||||
</button>
|
||||
`}
|
||||
<button class="btn btn-secondary" onclick="showSettings('${device.id}')">
|
||||
Settings
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="showCalibration('${device.id}')">
|
||||
Calibrate
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="removeDevice('${device.id}')">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function attachDeviceListeners(deviceId) {
|
||||
// Add any specific event listeners here if needed
|
||||
}
|
||||
|
||||
// Device actions
|
||||
async function startProcessing(deviceId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/start`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
showToast('Processing started', 'success');
|
||||
loadDevices();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to start: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start processing:', error);
|
||||
showToast('Failed to start processing', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function stopProcessing(deviceId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/stop`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
showToast('Processing stopped', 'success');
|
||||
loadDevices();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to stop: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to stop processing:', error);
|
||||
showToast('Failed to stop processing', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDevice(deviceId) {
|
||||
if (!confirm('Are you sure you want to remove this device?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
showToast('Device removed', 'success');
|
||||
loadDevices();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to remove: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to remove device:', error);
|
||||
showToast('Failed to remove device', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function showSettings(deviceId) {
|
||||
try {
|
||||
// Fetch current device data
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
showToast('Failed to load device settings', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const device = await response.json();
|
||||
|
||||
// Populate modal
|
||||
document.getElementById('settings-device-id').value = device.id;
|
||||
document.getElementById('settings-device-name').value = device.name;
|
||||
document.getElementById('settings-device-url').value = device.url;
|
||||
document.getElementById('settings-device-led-count').value = device.led_count;
|
||||
|
||||
// Set brightness (convert from 0.0-1.0 to 0-100)
|
||||
const brightnessPercent = Math.round((device.settings.brightness || 1.0) * 100);
|
||||
document.getElementById('settings-device-brightness').value = brightnessPercent;
|
||||
document.getElementById('brightness-value').textContent = brightnessPercent + '%';
|
||||
|
||||
// Show modal
|
||||
const modal = document.getElementById('device-settings-modal');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Focus first input
|
||||
setTimeout(() => {
|
||||
document.getElementById('settings-device-name').focus();
|
||||
}, 100);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load device settings:', error);
|
||||
showToast('Failed to load device settings', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeDeviceSettingsModal() {
|
||||
const modal = document.getElementById('device-settings-modal');
|
||||
const error = document.getElementById('settings-error');
|
||||
modal.style.display = 'none';
|
||||
error.style.display = 'none';
|
||||
}
|
||||
|
||||
async function saveDeviceSettings() {
|
||||
const deviceId = document.getElementById('settings-device-id').value;
|
||||
const name = document.getElementById('settings-device-name').value.trim();
|
||||
const url = document.getElementById('settings-device-url').value.trim();
|
||||
const led_count = parseInt(document.getElementById('settings-device-led-count').value);
|
||||
const brightnessPercent = parseInt(document.getElementById('settings-device-brightness').value);
|
||||
const brightness = brightnessPercent / 100.0; // Convert to 0.0-1.0
|
||||
const error = document.getElementById('settings-error');
|
||||
|
||||
// Validation
|
||||
if (!name || !url || !led_count || led_count < 1) {
|
||||
error.textContent = 'Please fill in all fields correctly';
|
||||
error.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Update device info (name, url, led_count)
|
||||
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ name, url, led_count })
|
||||
});
|
||||
|
||||
if (deviceResponse.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deviceResponse.ok) {
|
||||
const errorData = await deviceResponse.json();
|
||||
error.textContent = `Failed to update device: ${errorData.detail}`;
|
||||
error.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update settings (brightness)
|
||||
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ brightness })
|
||||
});
|
||||
|
||||
if (settingsResponse.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingsResponse.ok) {
|
||||
showToast('Device settings updated', 'success');
|
||||
closeDeviceSettingsModal();
|
||||
loadDevices();
|
||||
} else {
|
||||
const errorData = await settingsResponse.json();
|
||||
error.textContent = `Failed to update settings: ${errorData.detail}`;
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save device settings:', err);
|
||||
error.textContent = 'Failed to save settings';
|
||||
error.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Add device form handler
|
||||
async function handleAddDevice(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const name = document.getElementById('device-name').value;
|
||||
const url = document.getElementById('device-url').value;
|
||||
const led_count = parseInt(document.getElementById('device-led-count').value);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ name, url, led_count })
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
showToast('Device added successfully', 'success');
|
||||
event.target.reset();
|
||||
loadDevices();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to add device: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add device:', error);
|
||||
showToast('Failed to add device', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh
|
||||
function startAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
|
||||
refreshInterval = setInterval(() => {
|
||||
loadDevices();
|
||||
}, 2000); // Refresh every 2 seconds
|
||||
}
|
||||
|
||||
// Toast notifications
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = message;
|
||||
toast.className = `toast ${type} show`;
|
||||
|
||||
setTimeout(() => {
|
||||
toast.className = 'toast';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Calibration functions
|
||||
async function showCalibration(deviceId) {
|
||||
try {
|
||||
// Fetch current device data
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
showToast('Failed to load calibration', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const device = await response.json();
|
||||
const calibration = device.calibration;
|
||||
|
||||
// Store device ID and LED count
|
||||
document.getElementById('calibration-device-id').value = device.id;
|
||||
document.getElementById('cal-device-led-count').textContent = device.led_count;
|
||||
|
||||
// Set layout
|
||||
document.getElementById('cal-start-position').value = calibration.start_position;
|
||||
document.getElementById('cal-layout').value = calibration.layout;
|
||||
|
||||
// Set LED counts per edge
|
||||
const edgeCounts = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
calibration.segments.forEach(seg => {
|
||||
edgeCounts[seg.edge] = seg.led_count;
|
||||
});
|
||||
|
||||
document.getElementById('cal-top-leds').value = edgeCounts.top;
|
||||
document.getElementById('cal-right-leds').value = edgeCounts.right;
|
||||
document.getElementById('cal-bottom-leds').value = edgeCounts.bottom;
|
||||
document.getElementById('cal-left-leds').value = edgeCounts.left;
|
||||
|
||||
// Update preview
|
||||
updateCalibrationPreview();
|
||||
|
||||
// Show modal
|
||||
const modal = document.getElementById('calibration-modal');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load calibration:', error);
|
||||
showToast('Failed to load calibration', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeCalibrationModal() {
|
||||
const modal = document.getElementById('calibration-modal');
|
||||
const error = document.getElementById('calibration-error');
|
||||
modal.style.display = 'none';
|
||||
error.style.display = 'none';
|
||||
}
|
||||
|
||||
function updateCalibrationPreview() {
|
||||
// Update edge counts in preview
|
||||
document.getElementById('preview-top-count').textContent = document.getElementById('cal-top-leds').value;
|
||||
document.getElementById('preview-right-count').textContent = document.getElementById('cal-right-leds').value;
|
||||
document.getElementById('preview-bottom-count').textContent = document.getElementById('cal-bottom-leds').value;
|
||||
document.getElementById('preview-left-count').textContent = document.getElementById('cal-left-leds').value;
|
||||
|
||||
// Calculate total
|
||||
const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
|
||||
parseInt(document.getElementById('cal-right-leds').value || 0) +
|
||||
parseInt(document.getElementById('cal-bottom-leds').value || 0) +
|
||||
parseInt(document.getElementById('cal-left-leds').value || 0);
|
||||
document.getElementById('cal-total-leds').textContent = total;
|
||||
|
||||
// Update starting position indicator
|
||||
const startPos = document.getElementById('cal-start-position').value;
|
||||
const indicator = document.getElementById('start-indicator');
|
||||
|
||||
const positions = {
|
||||
'bottom_left': { bottom: '10px', left: '10px', top: 'auto', right: 'auto' },
|
||||
'bottom_right': { bottom: '10px', right: '10px', top: 'auto', left: 'auto' },
|
||||
'top_left': { top: '10px', left: '10px', bottom: 'auto', right: 'auto' },
|
||||
'top_right': { top: '10px', right: '10px', bottom: 'auto', left: 'auto' }
|
||||
};
|
||||
|
||||
const pos = positions[startPos];
|
||||
indicator.style.top = pos.top;
|
||||
indicator.style.right = pos.right;
|
||||
indicator.style.bottom = pos.bottom;
|
||||
indicator.style.left = pos.left;
|
||||
}
|
||||
|
||||
async function testCalibrationEdge(edge) {
|
||||
const deviceId = document.getElementById('calibration-device-id').value;
|
||||
const error = document.getElementById('calibration-error');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ edge, color: [255, 0, 0] }) // Red color
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
showToast(`Testing ${edge} edge (2 seconds)`, 'info');
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
error.textContent = `Test failed: ${errorData.detail}`;
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to test edge:', err);
|
||||
error.textContent = 'Failed to test edge';
|
||||
error.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCalibration() {
|
||||
const deviceId = document.getElementById('calibration-device-id').value;
|
||||
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count').textContent);
|
||||
const error = document.getElementById('calibration-error');
|
||||
|
||||
const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
|
||||
const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0);
|
||||
const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0);
|
||||
const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0);
|
||||
const total = topLeds + rightLeds + bottomLeds + leftLeds;
|
||||
|
||||
// Validation
|
||||
if (total !== deviceLedCount) {
|
||||
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
|
||||
error.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build calibration config
|
||||
const startPosition = document.getElementById('cal-start-position').value;
|
||||
const layout = document.getElementById('cal-layout').value;
|
||||
|
||||
// Build segments based on start position and direction
|
||||
const segments = [];
|
||||
let ledStart = 0;
|
||||
|
||||
const edgeOrder = getEdgeOrder(startPosition, layout);
|
||||
|
||||
const edgeCounts = {
|
||||
top: topLeds,
|
||||
right: rightLeds,
|
||||
bottom: bottomLeds,
|
||||
left: leftLeds
|
||||
};
|
||||
|
||||
edgeOrder.forEach(edge => {
|
||||
const count = edgeCounts[edge];
|
||||
if (count > 0) {
|
||||
segments.push({
|
||||
edge: edge,
|
||||
led_start: ledStart,
|
||||
led_count: count,
|
||||
reverse: shouldReverse(edge, startPosition, layout)
|
||||
});
|
||||
ledStart += count;
|
||||
}
|
||||
});
|
||||
|
||||
const calibration = {
|
||||
layout: layout,
|
||||
start_position: startPosition,
|
||||
segments: segments
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(calibration)
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
showToast('Calibration saved', 'success');
|
||||
closeCalibrationModal();
|
||||
loadDevices();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
error.textContent = `Failed to save: ${errorData.detail}`;
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save calibration:', err);
|
||||
error.textContent = 'Failed to save calibration';
|
||||
error.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function getEdgeOrder(startPosition, layout) {
|
||||
const clockwise = ['bottom', 'right', 'top', 'left'];
|
||||
const counterclockwise = ['bottom', 'left', 'top', 'right'];
|
||||
|
||||
const orders = {
|
||||
'bottom_left_clockwise': clockwise,
|
||||
'bottom_left_counterclockwise': counterclockwise,
|
||||
'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'],
|
||||
'bottom_right_counterclockwise': ['bottom', 'right', 'top', 'left'],
|
||||
'top_left_clockwise': ['top', 'right', 'bottom', 'left'],
|
||||
'top_left_counterclockwise': ['top', 'left', 'bottom', 'right'],
|
||||
'top_right_clockwise': ['top', 'left', 'bottom', 'right'],
|
||||
'top_right_counterclockwise': ['top', 'right', 'bottom', 'left']
|
||||
};
|
||||
|
||||
return orders[`${startPosition}_${layout}`] || clockwise;
|
||||
}
|
||||
|
||||
function shouldReverse(edge, startPosition, layout) {
|
||||
// Determine if this edge should be reversed based on LED strip direction
|
||||
const reverseRules = {
|
||||
'bottom_left_clockwise': { bottom: false, right: false, top: true, left: true },
|
||||
'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false },
|
||||
'bottom_right_clockwise': { bottom: true, right: false, top: false, left: true },
|
||||
'bottom_right_counterclockwise': { bottom: true, right: true, top: false, left: false },
|
||||
'top_left_clockwise': { top: false, right: false, bottom: true, left: true },
|
||||
'top_left_counterclockwise': { top: false, right: true, bottom: true, left: false },
|
||||
'top_right_clockwise': { top: true, right: false, bottom: false, left: true },
|
||||
'top_right_counterclockwise': { top: true, right: true, bottom: false, left: false }
|
||||
};
|
||||
|
||||
const rules = reverseRules[`${startPosition}_${layout}`];
|
||||
return rules ? rules[edge] : false;
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
});
|
||||
416
server/src/wled_controller/static/index.html
Normal file
416
server/src/wled_controller/static/index.html
Normal file
@@ -0,0 +1,416 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WLED Screen Controller</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>WLED Screen Controller</h1>
|
||||
<div class="server-info">
|
||||
<span id="server-version">Version: Loading...</span>
|
||||
<span id="server-status" class="status-badge">●</span>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme">
|
||||
<span id="theme-icon">🌙</span>
|
||||
</button>
|
||||
<span id="auth-status" style="margin-left: 10px; display: none;">
|
||||
<span id="logged-in-user" style="color: #4CAF50; margin-right: 8px;">●</span>
|
||||
</span>
|
||||
<button id="login-btn" class="btn btn-primary" onclick="showLogin()" style="display: none; padding: 6px 16px; font-size: 0.85rem; margin-left: 10px;">
|
||||
🔑 Login
|
||||
</button>
|
||||
<button id="logout-btn" class="btn btn-danger" onclick="logout()" style="display: none; padding: 6px 16px; font-size: 0.85rem; margin-left: 10px;">
|
||||
🚪 Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="displays-section">
|
||||
<h2>Available Displays</h2>
|
||||
<div id="displays-list" class="displays-grid">
|
||||
<div class="loading">Loading displays...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="devices-section">
|
||||
<h2>WLED Devices</h2>
|
||||
<div id="devices-list" class="devices-grid">
|
||||
<div class="loading">Loading devices...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="add-device-section">
|
||||
<h2>Add New Device</h2>
|
||||
<div class="info-banner" style="margin-bottom: 20px; padding: 12px; background: rgba(33, 150, 243, 0.1); border-left: 4px solid #2196F3; border-radius: 4px;">
|
||||
<strong>📱 WLED Configuration:</strong> Configure your WLED device (effects, segments, color order, power limits, etc.) using the
|
||||
<a href="https://kno.wled.ge/" target="_blank" rel="noopener" style="color: #2196F3; text-decoration: underline;">official WLED app</a>.
|
||||
This controller sends pixel color data and controls brightness per device.
|
||||
</div>
|
||||
<form id="add-device-form">
|
||||
<div class="form-group">
|
||||
<label for="device-name">Device Name:</label>
|
||||
<input type="text" id="device-name" placeholder="Living Room TV" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="device-url">WLED URL:</label>
|
||||
<input type="url" id="device-url" placeholder="http://192.168.1.100" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="device-led-count">LED Count:</label>
|
||||
<input type="number" id="device-led-count" value="150" min="1" required>
|
||||
<small class="input-hint">Number of LEDs configured in your WLED device</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Device</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<!-- Calibration Modal -->
|
||||
<div id="calibration-modal" class="modal">
|
||||
<div class="modal-content" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h2>📐 LED Calibration</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="calibration-device-id">
|
||||
<p style="margin-bottom: 20px; color: var(--text-secondary);">
|
||||
Configure how your LED strip is mapped to screen edges. Use test buttons to verify each edge lights up correctly.
|
||||
</p>
|
||||
|
||||
<!-- Visual Preview -->
|
||||
<div style="margin-bottom: 25px;">
|
||||
<div style="position: relative; width: 400px; height: 250px; margin: 0 auto; background: var(--card-bg); border: 2px solid var(--border-color); border-radius: 8px;">
|
||||
<!-- Screen representation -->
|
||||
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 300px; height: 180px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 4px; display: flex; align-items: center; justify-content: center; color: white; font-size: 14px;">
|
||||
Screen
|
||||
</div>
|
||||
|
||||
<!-- Edge labels -->
|
||||
<div style="position: absolute; top: 5px; left: 50%; transform: translateX(-50%); font-size: 12px; color: var(--text-secondary);">
|
||||
Top: <span id="preview-top-count">0</span> LEDs
|
||||
</div>
|
||||
<div style="position: absolute; right: 5px; top: 50%; transform: translateY(-50%) rotate(90deg); font-size: 12px; color: var(--text-secondary); white-space: nowrap;">
|
||||
Right: <span id="preview-right-count">0</span> LEDs
|
||||
</div>
|
||||
<div style="position: absolute; bottom: 5px; left: 50%; transform: translateX(-50%); font-size: 12px; color: var(--text-secondary);">
|
||||
Bottom: <span id="preview-bottom-count">0</span> LEDs
|
||||
</div>
|
||||
<div style="position: absolute; left: 5px; top: 50%; transform: translateY(-50%) rotate(-90deg); font-size: 12px; color: var(--text-secondary); white-space: nowrap;">
|
||||
Left: <span id="preview-left-count">0</span> LEDs
|
||||
</div>
|
||||
|
||||
<!-- Starting position indicator -->
|
||||
<div id="start-indicator" style="position: absolute; bottom: 10px; left: 10px; width: 12px; height: 12px; background: #4CAF50; border-radius: 50%; border: 2px solid white;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layout Configuration -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px;">
|
||||
<div class="form-group">
|
||||
<label for="cal-start-position">Starting Position:</label>
|
||||
<select id="cal-start-position" onchange="updateCalibrationPreview()">
|
||||
<option value="bottom_left">Bottom Left</option>
|
||||
<option value="bottom_right">Bottom Right</option>
|
||||
<option value="top_left">Top Left</option>
|
||||
<option value="top_right">Top Right</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cal-layout">Direction:</label>
|
||||
<select id="cal-layout" onchange="updateCalibrationPreview()">
|
||||
<option value="clockwise">Clockwise</option>
|
||||
<option value="counterclockwise">Counterclockwise</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LED Counts per Edge -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px;">
|
||||
<div class="form-group">
|
||||
<label for="cal-top-leds">Top LEDs:</label>
|
||||
<input type="number" id="cal-top-leds" min="0" value="0" oninput="updateCalibrationPreview()">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cal-right-leds">Right LEDs:</label>
|
||||
<input type="number" id="cal-right-leds" min="0" value="0" oninput="updateCalibrationPreview()">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cal-bottom-leds">Bottom LEDs:</label>
|
||||
<input type="number" id="cal-bottom-leds" min="0" value="0" oninput="updateCalibrationPreview()">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cal-left-leds">Left LEDs:</label>
|
||||
<input type="number" id="cal-left-leds" min="0" value="0" oninput="updateCalibrationPreview()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 10px; background: rgba(255, 193, 7, 0.1); border-left: 4px solid #FFC107; border-radius: 4px; margin-bottom: 20px;">
|
||||
<strong>Total LEDs:</strong> <span id="cal-total-leds">0</span> / <span id="cal-device-led-count">0</span>
|
||||
</div>
|
||||
|
||||
<!-- Test Buttons -->
|
||||
<div style="margin-bottom: 15px;">
|
||||
<p style="font-weight: 600; margin-bottom: 10px;">Test Edges (lights up each edge):</p>
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;">
|
||||
<button class="btn btn-secondary" onclick="testCalibrationEdge('top')" style="font-size: 0.9rem; padding: 8px;">
|
||||
⬆️ Top
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="testCalibrationEdge('right')" style="font-size: 0.9rem; padding: 8px;">
|
||||
➡️ Right
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="testCalibrationEdge('bottom')" style="font-size: 0.9rem; padding: 8px;">
|
||||
⬇️ Bottom
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="testCalibrationEdge('left')" style="font-size: 0.9rem; padding: 8px;">
|
||||
⬅️ Left
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="calibration-error" class="error-message" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeCalibrationModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveCalibration()">Save Calibration</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Settings Modal -->
|
||||
<div id="device-settings-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>⚙️ Device Settings</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="device-settings-form">
|
||||
<input type="hidden" id="settings-device-id">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="settings-device-name">Device Name:</label>
|
||||
<input type="text" id="settings-device-name" placeholder="Living Room TV" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="settings-device-url">WLED URL:</label>
|
||||
<input type="url" id="settings-device-url" placeholder="http://192.168.1.100" required>
|
||||
<small class="input-hint">IP address or hostname of your WLED device</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="settings-device-led-count">LED Count:</label>
|
||||
<input type="number" id="settings-device-led-count" min="1" required>
|
||||
<small class="input-hint">Number of LEDs configured in your WLED device</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="settings-device-brightness">Brightness: <span id="brightness-value">100%</span></label>
|
||||
<input type="range" id="settings-device-brightness" min="0" max="100" value="100"
|
||||
oninput="document.getElementById('brightness-value').textContent = this.value + '%'"
|
||||
style="width: 100%;">
|
||||
<small class="input-hint">Global brightness for this WLED device (0-100%)</small>
|
||||
</div>
|
||||
|
||||
<div id="settings-error" class="error-message" style="display: none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeDeviceSettingsModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveDeviceSettings()">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Modal -->
|
||||
<div id="api-key-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>🔑 Login to WLED Controller</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-description">
|
||||
Please enter your API key to authenticate and access the WLED Screen Controller.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="api-key-input">API Key:</label>
|
||||
<div class="password-input-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
id="api-key-input"
|
||||
placeholder="Enter your API key..."
|
||||
autocomplete="off"
|
||||
>
|
||||
<button type="button" class="password-toggle" onclick="togglePasswordVisibility()">
|
||||
👁️
|
||||
</button>
|
||||
</div>
|
||||
<small class="input-hint">Your API key will be stored securely in your browser's local storage.</small>
|
||||
</div>
|
||||
<div id="api-key-error" class="error-message" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeApiKeyModal()" id="modal-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="submitApiKey()">Login</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
<script>
|
||||
// Initialize theme
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
updateThemeIcon(savedTheme);
|
||||
|
||||
function updateThemeIcon(theme) {
|
||||
const icon = document.getElementById('theme-icon');
|
||||
icon.textContent = theme === 'dark' ? '☀️' : '🌙';
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
updateThemeIcon(newTheme);
|
||||
showToast(`Switched to ${newTheme} theme`, 'info');
|
||||
}
|
||||
|
||||
// Initialize auth state
|
||||
function updateAuthUI() {
|
||||
const apiKey = localStorage.getItem('wled_api_key');
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
const authStatus = document.getElementById('auth-status');
|
||||
const loggedInUser = document.getElementById('logged-in-user');
|
||||
|
||||
if (apiKey) {
|
||||
// Logged in
|
||||
loginBtn.style.display = 'none';
|
||||
logoutBtn.style.display = 'inline-block';
|
||||
authStatus.style.display = 'inline';
|
||||
|
||||
// Show masked key
|
||||
const masked = apiKey.substring(0, 8) + '...';
|
||||
loggedInUser.textContent = `● Authenticated`;
|
||||
loggedInUser.title = `API Key: ${masked}`;
|
||||
} else {
|
||||
// Logged out
|
||||
loginBtn.style.display = 'inline-block';
|
||||
logoutBtn.style.display = 'none';
|
||||
authStatus.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
showApiKeyModal('Enter your API key to login and access the controller.');
|
||||
document.getElementById('modal-cancel-btn').style.display = 'inline-block';
|
||||
}
|
||||
|
||||
function logout() {
|
||||
if (confirm('Are you sure you want to logout?')) {
|
||||
localStorage.removeItem('wled_api_key');
|
||||
apiKey = null;
|
||||
updateAuthUI();
|
||||
showToast('Logged out successfully', 'info');
|
||||
|
||||
// Clear the UI
|
||||
document.getElementById('devices-list').innerHTML = '<div class="loading">Please login to view devices</div>';
|
||||
document.getElementById('displays-list').innerHTML = '<div class="loading">Please login to view displays</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
updateAuthUI();
|
||||
|
||||
// Modal functions
|
||||
function togglePasswordVisibility() {
|
||||
const input = document.getElementById('api-key-input');
|
||||
const button = document.querySelector('.password-toggle');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
button.textContent = '🙈';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
button.textContent = '👁️';
|
||||
}
|
||||
}
|
||||
|
||||
function showApiKeyModal(message, hideCancel = false) {
|
||||
const modal = document.getElementById('api-key-modal');
|
||||
const description = document.querySelector('.modal-description');
|
||||
const input = document.getElementById('api-key-input');
|
||||
const error = document.getElementById('api-key-error');
|
||||
const cancelBtn = document.getElementById('modal-cancel-btn');
|
||||
|
||||
if (message) {
|
||||
description.textContent = message;
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
input.placeholder = 'Enter your API key...';
|
||||
error.style.display = 'none';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Hide cancel button if this is required login (no existing session)
|
||||
cancelBtn.style.display = hideCancel ? 'none' : 'inline-block';
|
||||
|
||||
setTimeout(() => input.focus(), 100);
|
||||
}
|
||||
|
||||
function closeApiKeyModal() {
|
||||
const modal = document.getElementById('api-key-modal');
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function submitApiKey() {
|
||||
const input = document.getElementById('api-key-input');
|
||||
const error = document.getElementById('api-key-error');
|
||||
const key = input.value.trim();
|
||||
|
||||
if (!key) {
|
||||
error.textContent = 'Please enter an API key';
|
||||
error.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the key
|
||||
localStorage.setItem('wled_api_key', key);
|
||||
apiKey = key;
|
||||
updateAuthUI();
|
||||
|
||||
closeApiKeyModal();
|
||||
showToast('Logged in successfully!', 'success');
|
||||
|
||||
// Reload data
|
||||
loadServerInfo();
|
||||
loadDisplays();
|
||||
loadDevices();
|
||||
|
||||
// Start auto-refresh if not already running
|
||||
if (!refreshInterval) {
|
||||
startAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Enter key in modal
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('api-key-input').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
submitApiKey();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
510
server/src/wled_controller/static/style.css
Normal file
510
server/src/wled_controller/static/style.css
Normal file
@@ -0,0 +1,510 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #4CAF50;
|
||||
--danger-color: #f44336;
|
||||
--warning-color: #ff9800;
|
||||
--info-color: #2196F3;
|
||||
}
|
||||
|
||||
/* Dark theme (default) */
|
||||
[data-theme="dark"] {
|
||||
--bg-color: #1a1a1a;
|
||||
--card-bg: #2d2d2d;
|
||||
--text-color: #e0e0e0;
|
||||
--border-color: #404040;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
[data-theme="light"] {
|
||||
--bg-color: #f5f5f5;
|
||||
--card-bg: #ffffff;
|
||||
--text-color: #333333;
|
||||
--border-color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Default to dark theme */
|
||||
body {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-color);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 1.5rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-badge.online {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.status-badge.offline {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.displays-grid,
|
||||
.devices-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.processing {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.idle {
|
||||
background: var(--warning-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.error {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
flex: 1 1 auto;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.display-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.display-index {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--info-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.add-device-section {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="url"],
|
||||
input[type="number"],
|
||||
input[type="password"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
/* Better password field appearance */
|
||||
input[type="password"] {
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
/* Remove browser autofill styling */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0 1000px var(--bg-color) inset;
|
||||
-webkit-text-fill-color: var(--text-color);
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 15px 20px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: var(--danger-color);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background: var(--info-color);
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
background: var(--bg-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 2000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 24px 24px 16px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-description {
|
||||
color: #999;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.password-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.password-input-wrapper input {
|
||||
flex: 1;
|
||||
padding-right: 45px;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
padding: 8px;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border: 1px solid var(--danger-color);
|
||||
color: var(--danger-color);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-top: 15px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 24px 24px 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
.theme-toggle {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
transition: transform 0.2s;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.displays-grid,
|
||||
.devices-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user