Split monolithic app.js into native ES modules
Replace the single 7034-line app.js with 17 ES module files organized into core/ (state, api, i18n, ui) and features/ (calibration, dashboard, device-discovery, devices, displays, kc-targets, pattern-templates, profiles, streams, tabs, targets, tutorials) with an app.js entry point that registers ~90 onclick globals on window. No bundler needed — FastAPI serves modules directly via <script type="module">. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
453
server/src/wled_controller/static/js/features/devices.js
Normal file
453
server/src/wled_controller/static/js/features/devices.js
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Device cards — settings modal, brightness, power, color.
|
||||
*/
|
||||
|
||||
import {
|
||||
settingsInitialValues, setSettingsInitialValues,
|
||||
_deviceBrightnessCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, escapeHtml, isSerialDevice, handle401Error } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { lockBody, unlockBody, showToast, showConfirm } from '../core/ui.js';
|
||||
|
||||
export function createDeviceCard(device) {
|
||||
const state = device.state || {};
|
||||
|
||||
const devOnline = state.device_online || false;
|
||||
const devLatency = state.device_latency_ms;
|
||||
const devName = state.device_name;
|
||||
const devVersion = state.device_version;
|
||||
const devLastChecked = state.device_last_checked;
|
||||
|
||||
let healthClass, healthTitle, healthLabel;
|
||||
if (devLastChecked === null || devLastChecked === undefined) {
|
||||
healthClass = 'health-unknown';
|
||||
healthTitle = t('device.health.checking');
|
||||
healthLabel = '';
|
||||
} else if (devOnline) {
|
||||
healthClass = 'health-online';
|
||||
healthTitle = `${t('device.health.online')}`;
|
||||
if (devName) healthTitle += ` - ${devName}`;
|
||||
if (devVersion) healthTitle += ` v${devVersion}`;
|
||||
if (devLatency !== null && devLatency !== undefined) healthTitle += ` (${Math.round(devLatency)}ms)`;
|
||||
healthLabel = '';
|
||||
} else {
|
||||
healthClass = 'health-offline';
|
||||
healthTitle = t('device.health.offline');
|
||||
if (state.device_error) healthTitle += `: ${state.device_error}`;
|
||||
healthLabel = `<span class="health-latency offline">${t('device.health.offline')}</span>`;
|
||||
}
|
||||
|
||||
const ledCount = state.device_led_count || device.led_count;
|
||||
|
||||
return `
|
||||
<div class="card" data-device-id="${device.id}">
|
||||
<div class="card-top-actions">
|
||||
${(device.capabilities || []).includes('power_control') ? `<button class="card-top-btn card-power-btn" onclick="toggleDevicePower('${device.id}')" title="${t('device.button.power_toggle')}">⏻</button>` : ''}
|
||||
<button class="card-remove-btn" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">✕</button>
|
||||
</div>
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
||||
${device.name || device.id}
|
||||
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">🌐</span></a>` : (device.url && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
|
||||
${healthLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-subtitle">
|
||||
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
|
||||
${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">💡 ${ledCount}</span>` : ''}
|
||||
${state.device_led_type ? `<span class="card-meta">🔌 ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
||||
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
||||
${(device.capabilities || []).includes('static_color') ? `<span class="card-meta static-color-control" data-color-wrap="${device.id}"><input type="color" class="static-color-picker" value="${device.static_color ? rgbToHex(...device.static_color) : '#000000'}" data-device-color="${device.id}" onchange="saveDeviceStaticColor('${device.id}', this.value)" title="${t('device.static_color.hint')}"><button class="btn-clear-color" onclick="clearDeviceStaticColor('${device.id}')" title="${t('device.static_color.clear')}" ${!device.static_color ? 'style="display:none"' : ''}>✕</button></span>` : ''}
|
||||
</div>
|
||||
${(device.capabilities || []).includes('brightness_control') ? `
|
||||
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
|
||||
<input type="range" class="brightness-slider" min="0" max="255"
|
||||
value="${_deviceBrightnessCache[device.id] ?? 0}" data-device-brightness="${device.id}"
|
||||
oninput="updateBrightnessLabel('${device.id}', this.value)"
|
||||
onchange="saveCardBrightness('${device.id}', this.value)"
|
||||
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
|
||||
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
|
||||
</div>` : ''}
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
|
||||
⚙️
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
|
||||
📐
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export async function toggleDevicePower(deviceId) {
|
||||
try {
|
||||
const getResp = await fetch(`${API_BASE}/devices/${deviceId}/power`, { headers: getHeaders() });
|
||||
if (getResp.status === 401) { handle401Error(); return; }
|
||||
if (!getResp.ok) { showToast('Failed to get power state', 'error'); return; }
|
||||
const current = await getResp.json();
|
||||
const newState = !current.on;
|
||||
|
||||
const setResp = await fetch(`${API_BASE}/devices/${deviceId}/power`, {
|
||||
method: 'PUT',
|
||||
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ on: newState })
|
||||
});
|
||||
if (setResp.status === 401) { handle401Error(); return; }
|
||||
if (setResp.ok) {
|
||||
showToast(t(newState ? 'device.power.on_success' : 'device.power.off_success'), 'success');
|
||||
} else {
|
||||
const error = await setResp.json();
|
||||
showToast(error.detail || 'Failed', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to toggle power', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function attachDeviceListeners(deviceId) {
|
||||
// Add any specific event listeners here if needed
|
||||
}
|
||||
|
||||
export async function removeDevice(deviceId) {
|
||||
const confirmed = await showConfirm(t('device.remove.confirm'));
|
||||
if (!confirmed) 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');
|
||||
window.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');
|
||||
}
|
||||
}
|
||||
|
||||
export async function showSettings(deviceId) {
|
||||
try {
|
||||
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() });
|
||||
if (deviceResponse.status === 401) { handle401Error(); return; }
|
||||
if (!deviceResponse.ok) { showToast('Failed to load device settings', 'error'); return; }
|
||||
|
||||
const device = await deviceResponse.json();
|
||||
const isAdalight = isSerialDevice(device.device_type);
|
||||
|
||||
document.getElementById('settings-device-id').value = device.id;
|
||||
document.getElementById('settings-device-name').value = device.name;
|
||||
document.getElementById('settings-health-interval').value = 30;
|
||||
|
||||
const urlGroup = document.getElementById('settings-url-group');
|
||||
const serialGroup = document.getElementById('settings-serial-port-group');
|
||||
if (isAdalight) {
|
||||
urlGroup.style.display = 'none';
|
||||
document.getElementById('settings-device-url').removeAttribute('required');
|
||||
serialGroup.style.display = '';
|
||||
_populateSettingsSerialPorts(device.url);
|
||||
} else {
|
||||
urlGroup.style.display = '';
|
||||
document.getElementById('settings-device-url').setAttribute('required', '');
|
||||
document.getElementById('settings-device-url').value = device.url;
|
||||
serialGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
const caps = device.capabilities || [];
|
||||
const ledCountGroup = document.getElementById('settings-led-count-group');
|
||||
if (caps.includes('manual_led_count')) {
|
||||
ledCountGroup.style.display = '';
|
||||
document.getElementById('settings-led-count').value = device.led_count || '';
|
||||
} else {
|
||||
ledCountGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
const baudRateGroup = document.getElementById('settings-baud-rate-group');
|
||||
if (isAdalight) {
|
||||
baudRateGroup.style.display = '';
|
||||
const baudSelect = document.getElementById('settings-baud-rate');
|
||||
if (device.baud_rate) {
|
||||
baudSelect.value = String(device.baud_rate);
|
||||
} else {
|
||||
baudSelect.value = '115200';
|
||||
}
|
||||
updateSettingsBaudFpsHint();
|
||||
} else {
|
||||
baudRateGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown;
|
||||
|
||||
setSettingsInitialValues({
|
||||
name: device.name,
|
||||
url: device.url,
|
||||
led_count: String(device.led_count || ''),
|
||||
baud_rate: String(device.baud_rate || '115200'),
|
||||
device_type: device.device_type,
|
||||
capabilities: caps,
|
||||
state_check_interval: '30',
|
||||
auto_shutdown: !!device.auto_shutdown,
|
||||
});
|
||||
|
||||
const modal = document.getElementById('device-settings-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
|
||||
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 _getSettingsUrl() {
|
||||
if (isSerialDevice(settingsInitialValues.device_type)) {
|
||||
return document.getElementById('settings-serial-port').value;
|
||||
}
|
||||
return document.getElementById('settings-device-url').value.trim();
|
||||
}
|
||||
|
||||
export function isSettingsDirty() {
|
||||
const ledCountDirty = (settingsInitialValues.capabilities || []).includes('manual_led_count')
|
||||
&& document.getElementById('settings-led-count').value !== settingsInitialValues.led_count;
|
||||
return (
|
||||
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
|
||||
_getSettingsUrl() !== settingsInitialValues.url ||
|
||||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ||
|
||||
document.getElementById('settings-auto-shutdown').checked !== settingsInitialValues.auto_shutdown ||
|
||||
ledCountDirty
|
||||
);
|
||||
}
|
||||
|
||||
export function forceCloseDeviceSettingsModal() {
|
||||
const modal = document.getElementById('device-settings-modal');
|
||||
const error = document.getElementById('settings-error');
|
||||
modal.style.display = 'none';
|
||||
error.style.display = 'none';
|
||||
unlockBody();
|
||||
setSettingsInitialValues({});
|
||||
}
|
||||
|
||||
export async function closeDeviceSettingsModal() {
|
||||
if (isSettingsDirty()) {
|
||||
const confirmed = await showConfirm(t('modal.discard_changes'));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
forceCloseDeviceSettingsModal();
|
||||
}
|
||||
|
||||
export async function saveDeviceSettings() {
|
||||
const deviceId = document.getElementById('settings-device-id').value;
|
||||
const name = document.getElementById('settings-device-name').value.trim();
|
||||
const url = _getSettingsUrl();
|
||||
const error = document.getElementById('settings-error');
|
||||
|
||||
if (!name || !url) {
|
||||
error.textContent = 'Please fill in all fields correctly';
|
||||
error.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = { name, url, auto_shutdown: document.getElementById('settings-auto-shutdown').checked };
|
||||
const ledCountInput = document.getElementById('settings-led-count');
|
||||
if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) {
|
||||
body.led_count = parseInt(ledCountInput.value, 10);
|
||||
}
|
||||
if (isSerialDevice(settingsInitialValues.device_type)) {
|
||||
const baudVal = document.getElementById('settings-baud-rate').value;
|
||||
if (baudVal) body.baud_rate = parseInt(baudVal, 10);
|
||||
}
|
||||
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
showToast(t('settings.saved'), 'success');
|
||||
forceCloseDeviceSettingsModal();
|
||||
window.loadDevices();
|
||||
} catch (err) {
|
||||
console.error('Failed to save device settings:', err);
|
||||
error.textContent = 'Failed to save settings';
|
||||
error.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Brightness
|
||||
export function updateBrightnessLabel(deviceId, value) {
|
||||
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
|
||||
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
|
||||
}
|
||||
|
||||
export async function saveCardBrightness(deviceId, value) {
|
||||
const bri = parseInt(value);
|
||||
_deviceBrightnessCache[deviceId] = bri;
|
||||
try {
|
||||
await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ brightness: bri })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to update brightness:', err);
|
||||
showToast('Failed to update brightness', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDeviceBrightness(deviceId) {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
_deviceBrightnessCache[deviceId] = data.brightness;
|
||||
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
|
||||
if (slider) {
|
||||
slider.value = data.brightness;
|
||||
slider.title = Math.round(data.brightness / 255 * 100) + '%';
|
||||
slider.disabled = false;
|
||||
}
|
||||
const wrap = document.querySelector(`[data-brightness-wrap="${deviceId}"]`);
|
||||
if (wrap) wrap.classList.remove('brightness-loading');
|
||||
} catch (err) {
|
||||
// Silently fail — device may be offline
|
||||
}
|
||||
}
|
||||
|
||||
// Static color helpers
|
||||
export function rgbToHex(r, g, b) {
|
||||
return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : null;
|
||||
}
|
||||
|
||||
export async function saveDeviceStaticColor(deviceId, hexValue) {
|
||||
const rgb = hexToRgb(hexValue);
|
||||
try {
|
||||
await fetch(`${API_BASE}/devices/${deviceId}/color`, {
|
||||
method: 'PUT',
|
||||
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ color: rgb })
|
||||
});
|
||||
const wrap = document.querySelector(`[data-color-wrap="${deviceId}"]`);
|
||||
if (wrap) {
|
||||
const clearBtn = wrap.querySelector('.btn-clear-color');
|
||||
if (clearBtn) clearBtn.style.display = '';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to set static color:', err);
|
||||
showToast('Failed to set static color', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearDeviceStaticColor(deviceId) {
|
||||
try {
|
||||
await fetch(`${API_BASE}/devices/${deviceId}/color`, {
|
||||
method: 'PUT',
|
||||
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ color: null })
|
||||
});
|
||||
const picker = document.querySelector(`[data-device-color="${deviceId}"]`);
|
||||
if (picker) picker.value = '#000000';
|
||||
const wrap = document.querySelector(`[data-color-wrap="${deviceId}"]`);
|
||||
if (wrap) {
|
||||
const clearBtn = wrap.querySelector('.btn-clear-color');
|
||||
if (clearBtn) clearBtn.style.display = 'none';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to clear static color:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// FPS hint helpers (shared with device-discovery)
|
||||
export function _computeMaxFps(baudRate, ledCount, deviceType) {
|
||||
if (!baudRate || !ledCount || ledCount < 1) return null;
|
||||
const overhead = deviceType === 'ambiled' ? 1 : 6;
|
||||
const bitsPerFrame = (ledCount * 3 + overhead) * 10;
|
||||
return Math.floor(baudRate / bitsPerFrame);
|
||||
}
|
||||
|
||||
export function _renderFpsHint(hintEl, baudRate, ledCount, deviceType) {
|
||||
const fps = _computeMaxFps(baudRate, ledCount, deviceType);
|
||||
if (fps !== null) {
|
||||
hintEl.textContent = `Max FPS ≈ ${fps}`;
|
||||
hintEl.style.display = '';
|
||||
} else {
|
||||
hintEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSettingsBaudFpsHint() {
|
||||
const hintEl = document.getElementById('settings-baud-fps-hint');
|
||||
const baudRate = parseInt(document.getElementById('settings-baud-rate').value, 10);
|
||||
const ledCount = parseInt(document.getElementById('settings-led-count').value, 10);
|
||||
_renderFpsHint(hintEl, baudRate, ledCount, settingsInitialValues.device_type);
|
||||
}
|
||||
|
||||
// Settings serial port population (used from showSettings)
|
||||
async function _populateSettingsSerialPorts(currentUrl) {
|
||||
const select = document.getElementById('settings-serial-port');
|
||||
select.innerHTML = '';
|
||||
const loadingOpt = document.createElement('option');
|
||||
loadingOpt.value = currentUrl;
|
||||
loadingOpt.textContent = currentUrl + ' ⏳';
|
||||
select.appendChild(loadingOpt);
|
||||
|
||||
try {
|
||||
const discoverType = settingsInitialValues.device_type || 'adalight';
|
||||
const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const devices = data.devices || [];
|
||||
|
||||
select.innerHTML = '';
|
||||
let currentFound = false;
|
||||
devices.forEach(device => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = device.url;
|
||||
opt.textContent = device.name;
|
||||
if (device.url === currentUrl) currentFound = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
if (!currentFound) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = currentUrl;
|
||||
opt.textContent = currentUrl;
|
||||
select.insertBefore(opt, select.firstChild);
|
||||
}
|
||||
select.value = currentUrl;
|
||||
} catch (err) {
|
||||
console.error('Failed to discover serial ports:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDevices() {
|
||||
await window.loadTargetsTab();
|
||||
}
|
||||
Reference in New Issue
Block a user