Add LED device abstraction layer for multi-controller support
Introduce abstract LEDClient base class with factory pattern so new LED controller types can plug in alongside WLED. ProcessorManager is now fully type-agnostic — all device-specific logic (health checks, state snapshot/restore, fast send) lives behind the LEDClient interface. - New led_client.py: LEDClient ABC, DeviceHealth, factory functions - WLEDClient inherits LEDClient, encapsulates WLED health checks and state management - device_type field on Device storage model (defaults to "wled") - Rename target_type "wled" → "led" with backward-compat migration - Frontend: "WLED" tab → "LED", device type badge, type selector in add-device modal, device type shown in target device dropdown - All wled_* API fields renamed to device_* for generic naming Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -614,33 +614,33 @@ async function loadDevices() {
|
||||
function createDeviceCard(device) {
|
||||
const state = device.state || {};
|
||||
|
||||
// WLED device health indicator
|
||||
const wledOnline = state.wled_online || false;
|
||||
const wledLatency = state.wled_latency_ms;
|
||||
const wledName = state.wled_name;
|
||||
const wledVersion = state.wled_version;
|
||||
const wledLastChecked = state.wled_last_checked;
|
||||
// Device health indicator
|
||||
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 (wledLastChecked === null || wledLastChecked === undefined) {
|
||||
if (devLastChecked === null || devLastChecked === undefined) {
|
||||
healthClass = 'health-unknown';
|
||||
healthTitle = t('device.health.checking');
|
||||
healthLabel = '';
|
||||
} else if (wledOnline) {
|
||||
} else if (devOnline) {
|
||||
healthClass = 'health-online';
|
||||
healthTitle = `${t('device.health.online')}`;
|
||||
if (wledName) healthTitle += ` - ${wledName}`;
|
||||
if (wledVersion) healthTitle += ` v${wledVersion}`;
|
||||
if (wledLatency !== null && wledLatency !== undefined) healthTitle += ` (${Math.round(wledLatency)}ms)`;
|
||||
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.wled_error) healthTitle += `: ${state.wled_error}`;
|
||||
if (state.device_error) healthTitle += `: ${state.device_error}`;
|
||||
healthLabel = `<span class="health-latency offline">${t('device.health.offline')}</span>`;
|
||||
}
|
||||
|
||||
const ledCount = state.wled_led_count || device.led_count;
|
||||
const ledCount = state.device_led_count || device.led_count;
|
||||
|
||||
return `
|
||||
<div class="card" data-device-id="${device.id}">
|
||||
@@ -654,9 +654,10 @@ function createDeviceCard(device) {
|
||||
</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.wled_led_type ? `<span class="card-meta">🔌 ${state.wled_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
||||
<span class="card-meta" title="${state.wled_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.wled_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></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>
|
||||
</div>
|
||||
<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"
|
||||
@@ -903,7 +904,8 @@ async function handleAddDevice(event) {
|
||||
}
|
||||
|
||||
try {
|
||||
const body = { name, url };
|
||||
const deviceType = document.getElementById('device-type')?.value || 'wled';
|
||||
const body = { name, url, device_type: deviceType };
|
||||
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
||||
if (lastTemplateId) {
|
||||
body.capture_template_id = lastTemplateId;
|
||||
@@ -1060,6 +1062,10 @@ async function showCalibration(deviceId) {
|
||||
// Set skip LEDs
|
||||
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0;
|
||||
document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0;
|
||||
updateOffsetSkipLock();
|
||||
|
||||
// Set border width
|
||||
document.getElementById('cal-border-width').value = calibration.border_width || 10;
|
||||
|
||||
// Initialize edge spans
|
||||
window.edgeSpans = {
|
||||
@@ -1081,6 +1087,7 @@ async function showCalibration(deviceId) {
|
||||
spans: JSON.stringify(window.edgeSpans),
|
||||
skip_start: String(calibration.skip_leds_start || 0),
|
||||
skip_end: String(calibration.skip_leds_end || 0),
|
||||
border_width: String(calibration.border_width || 10),
|
||||
};
|
||||
|
||||
// Initialize test mode state for this device
|
||||
@@ -1131,7 +1138,8 @@ function isCalibrationDirty() {
|
||||
document.getElementById('cal-left-leds').value !== calibrationInitialValues.left ||
|
||||
JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans ||
|
||||
document.getElementById('cal-skip-start').value !== calibrationInitialValues.skip_start ||
|
||||
document.getElementById('cal-skip-end').value !== calibrationInitialValues.skip_end
|
||||
document.getElementById('cal-skip-end').value !== calibrationInitialValues.skip_end ||
|
||||
document.getElementById('cal-border-width').value !== calibrationInitialValues.border_width
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1160,6 +1168,17 @@ async function closeCalibrationModal() {
|
||||
forceCloseCalibrationModal();
|
||||
}
|
||||
|
||||
function updateOffsetSkipLock() {
|
||||
const offsetEl = document.getElementById('cal-offset');
|
||||
const skipStartEl = document.getElementById('cal-skip-start');
|
||||
const skipEndEl = document.getElementById('cal-skip-end');
|
||||
const hasOffset = parseInt(offsetEl.value || 0) > 0;
|
||||
const hasSkip = parseInt(skipStartEl.value || 0) > 0 || parseInt(skipEndEl.value || 0) > 0;
|
||||
skipStartEl.disabled = hasOffset;
|
||||
skipEndEl.disabled = hasOffset;
|
||||
offsetEl.disabled = hasSkip;
|
||||
}
|
||||
|
||||
function updateCalibrationPreview() {
|
||||
// Calculate total from edge inputs
|
||||
const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
|
||||
@@ -1774,6 +1793,7 @@ async function saveCalibration() {
|
||||
span_left_end: spans.left?.end ?? 1,
|
||||
skip_leds_start: parseInt(document.getElementById('cal-skip-start').value || 0),
|
||||
skip_leds_end: parseInt(document.getElementById('cal-skip-end').value || 0),
|
||||
border_width: parseInt(document.getElementById('cal-border-width').value) || 10,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1935,11 +1955,13 @@ const calibrationTutorialSteps = [
|
||||
{ selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' },
|
||||
{ selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' },
|
||||
{ selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' },
|
||||
{ selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' },
|
||||
{ selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' },
|
||||
{ selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' },
|
||||
{ selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' },
|
||||
{ selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds', position: 'top' }
|
||||
{ selector: '.preview-screen-border-width', textKey: 'calibration.tip.border_width', position: 'bottom' },
|
||||
{ selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' },
|
||||
{ selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds_start', position: 'top' },
|
||||
{ selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', position: 'top' }
|
||||
];
|
||||
|
||||
const deviceTutorialSteps = [
|
||||
@@ -3820,13 +3842,14 @@ async function showTargetEditor(targetId = null) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.id;
|
||||
const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : '';
|
||||
opt.textContent = `${d.name}${shortUrl ? ' (' + shortUrl + ')' : ''}`;
|
||||
const devType = (d.device_type || 'wled').toUpperCase();
|
||||
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
|
||||
deviceSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Populate source select
|
||||
const sourceSelect = document.getElementById('target-editor-source');
|
||||
sourceSelect.innerHTML = '<option value="">-- No source --</option>';
|
||||
sourceSelect.innerHTML = '';
|
||||
sources.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
@@ -3847,7 +3870,6 @@ async function showTargetEditor(targetId = null) {
|
||||
sourceSelect.value = target.picture_source_id || '';
|
||||
document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30;
|
||||
document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30;
|
||||
document.getElementById('target-editor-border-width').value = target.settings?.border_width ?? 10;
|
||||
document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average';
|
||||
document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3;
|
||||
document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3;
|
||||
@@ -3862,7 +3884,6 @@ async function showTargetEditor(targetId = null) {
|
||||
sourceSelect.value = '';
|
||||
document.getElementById('target-editor-fps').value = 30;
|
||||
document.getElementById('target-editor-fps-value').textContent = '30';
|
||||
document.getElementById('target-editor-border-width').value = 10;
|
||||
document.getElementById('target-editor-interpolation').value = 'average';
|
||||
document.getElementById('target-editor-smoothing').value = 0.3;
|
||||
document.getElementById('target-editor-smoothing-value').textContent = '0.3';
|
||||
@@ -3876,7 +3897,6 @@ async function showTargetEditor(targetId = null) {
|
||||
device: deviceSelect.value,
|
||||
source: sourceSelect.value,
|
||||
fps: document.getElementById('target-editor-fps').value,
|
||||
border_width: document.getElementById('target-editor-border-width').value,
|
||||
interpolation: document.getElementById('target-editor-interpolation').value,
|
||||
smoothing: document.getElementById('target-editor-smoothing').value,
|
||||
standby_interval: document.getElementById('target-editor-standby-interval').value,
|
||||
@@ -3901,7 +3921,6 @@ function isTargetEditorDirty() {
|
||||
document.getElementById('target-editor-device').value !== targetEditorInitialValues.device ||
|
||||
document.getElementById('target-editor-source').value !== targetEditorInitialValues.source ||
|
||||
document.getElementById('target-editor-fps').value !== targetEditorInitialValues.fps ||
|
||||
document.getElementById('target-editor-border-width').value !== targetEditorInitialValues.border_width ||
|
||||
document.getElementById('target-editor-interpolation').value !== targetEditorInitialValues.interpolation ||
|
||||
document.getElementById('target-editor-smoothing').value !== targetEditorInitialValues.smoothing ||
|
||||
document.getElementById('target-editor-standby-interval').value !== targetEditorInitialValues.standby_interval
|
||||
@@ -3929,7 +3948,6 @@ async function saveTargetEditor() {
|
||||
const deviceId = document.getElementById('target-editor-device').value;
|
||||
const sourceId = document.getElementById('target-editor-source').value;
|
||||
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
|
||||
const borderWidth = parseInt(document.getElementById('target-editor-border-width').value) || 10;
|
||||
const interpolation = document.getElementById('target-editor-interpolation').value;
|
||||
const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value);
|
||||
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
|
||||
@@ -3947,7 +3965,6 @@ async function saveTargetEditor() {
|
||||
picture_source_id: sourceId,
|
||||
settings: {
|
||||
fps: fps,
|
||||
border_width: borderWidth,
|
||||
interpolation_mode: interpolation,
|
||||
smoothing: smoothing,
|
||||
standby_interval: standbyInterval,
|
||||
@@ -3963,7 +3980,7 @@ async function saveTargetEditor() {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
payload.target_type = 'wled';
|
||||
payload.target_type = 'led';
|
||||
response = await fetch(`${API_BASE}/picture-targets`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
@@ -4083,14 +4100,16 @@ async function loadTargetsTab() {
|
||||
devicesWithState.forEach(d => { deviceMap[d.id] = d; });
|
||||
|
||||
// Group by type
|
||||
const wledDevices = devicesWithState;
|
||||
const wledTargets = targetsWithState.filter(t => t.target_type === 'wled');
|
||||
const ledDevices = devicesWithState;
|
||||
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
|
||||
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
|
||||
|
||||
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'wled';
|
||||
// Backward compat: map stored "wled" sub-tab to "led"
|
||||
let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
|
||||
if (activeSubTab === 'wled') activeSubTab = 'led';
|
||||
|
||||
const subTabs = [
|
||||
{ key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length },
|
||||
{ key: 'led', icon: '💡', titleKey: 'targets.subtab.led', count: ledDevices.length + ledTargets.length },
|
||||
{ key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
|
||||
];
|
||||
|
||||
@@ -4098,13 +4117,13 @@ async function loadTargetsTab() {
|
||||
`<button class="target-sub-tab-btn stream-tab-btn${tab.key === activeSubTab ? ' active' : ''}" data-target-sub-tab="${tab.key}" onclick="switchTargetSubTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
|
||||
).join('')}</div>`;
|
||||
|
||||
// WLED panel: devices section + targets section
|
||||
const wledPanel = `
|
||||
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'wled' ? ' active' : ''}" id="target-sub-tab-wled">
|
||||
// LED panel: devices section + targets section
|
||||
const ledPanel = `
|
||||
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
|
||||
<div class="subtab-section">
|
||||
<h3 class="subtab-section-header">${t('targets.section.devices')}</h3>
|
||||
<div class="devices-grid">
|
||||
${wledDevices.map(device => createDeviceCard(device)).join('')}
|
||||
${ledDevices.map(device => createDeviceCard(device)).join('')}
|
||||
<div class="template-card add-template-card" onclick="showAddDevice()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>
|
||||
@@ -4113,7 +4132,7 @@ async function loadTargetsTab() {
|
||||
<div class="subtab-section">
|
||||
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
|
||||
<div class="devices-grid">
|
||||
${wledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
|
||||
${ledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
|
||||
<div class="template-card add-template-card" onclick="showTargetEditor()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>
|
||||
@@ -4144,9 +4163,9 @@ async function loadTargetsTab() {
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = tabBar + wledPanel + kcPanel;
|
||||
container.innerHTML = tabBar + ledPanel + kcPanel;
|
||||
|
||||
// Attach event listeners and fetch WLED brightness for device cards
|
||||
// Attach event listeners and fetch brightness for device cards
|
||||
devicesWithState.forEach(device => {
|
||||
attachDeviceListeners(device.id);
|
||||
fetchDeviceBrightness(device.id);
|
||||
@@ -4186,12 +4205,12 @@ function createTargetCard(target, deviceMap, sourceMap) {
|
||||
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
|
||||
|
||||
// Health info from target state (forwarded from device)
|
||||
const wledOnline = state.wled_online || false;
|
||||
const devOnline = state.device_online || false;
|
||||
let healthClass = 'health-unknown';
|
||||
let healthTitle = '';
|
||||
if (state.wled_last_checked !== null && state.wled_last_checked !== undefined) {
|
||||
healthClass = wledOnline ? 'health-online' : 'health-offline';
|
||||
healthTitle = wledOnline ? t('device.health.online') : t('device.health.offline');
|
||||
if (state.device_last_checked !== null && state.device_last_checked !== undefined) {
|
||||
healthClass = devOnline ? 'health-online' : 'health-offline';
|
||||
healthTitle = devOnline ? t('device.health.online') : t('device.health.offline');
|
||||
}
|
||||
|
||||
return `
|
||||
|
||||
@@ -95,6 +95,10 @@
|
||||
<span id="direction-icon">↻</span> <span id="direction-label">CW</span>
|
||||
</button>
|
||||
<div class="preview-screen-total" onclick="toggleEdgeInputs()" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
|
||||
<div class="preview-screen-border-width">
|
||||
<label for="cal-border-width" data-i18n="calibration.border_width">Border (px):</label>
|
||||
<input type="number" id="cal-border-width" min="1" max="100" value="10">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edge bars with span controls and LED count inputs -->
|
||||
@@ -171,7 +175,7 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.offset.hint">Distance from physical LED 0 to the start corner (along strip direction)</small>
|
||||
<input type="number" id="cal-offset" min="0" value="0" oninput="updateCalibrationPreview()">
|
||||
<input type="number" id="cal-offset" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
@@ -179,7 +183,7 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.skip_start.hint">Number of LEDs to turn off at the beginning of the strip (0 = none)</small>
|
||||
<input type="number" id="cal-skip-start" min="0" value="0" oninput="updateCalibrationPreview()">
|
||||
<input type="number" id="cal-skip-start" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
@@ -187,7 +191,7 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.skip_end.hint">Number of LEDs to turn off at the end of the strip (0 = none)</small>
|
||||
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateCalibrationPreview()">
|
||||
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -235,10 +239,10 @@
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="settings-device-url" data-i18n="device.url">WLED URL:</label>
|
||||
<label for="settings-device-url" data-i18n="device.url">URL:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.url.hint">IP address or hostname of the device</small>
|
||||
<input type="url" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
||||
</div>
|
||||
|
||||
@@ -247,7 +251,7 @@
|
||||
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.health_interval.hint">How often to check the WLED device status (5-600 seconds)</small>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.health_interval.hint">How often to check the device status (5-600 seconds)</small>
|
||||
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
|
||||
</div>
|
||||
|
||||
@@ -279,10 +283,10 @@
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="target-editor-device" data-i18n="targets.device">WLED Device:</label>
|
||||
<label for="target-editor-device" data-i18n="targets.device">Device:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="targets.device.hint">Select the WLED device to stream to</small>
|
||||
<small class="input-hint" style="display:none" data-i18n="targets.device.hint">Select the LED device to send data to</small>
|
||||
<select id="target-editor-device"></select>
|
||||
</div>
|
||||
|
||||
@@ -307,15 +311,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="target-editor-border-width" data-i18n="targets.border_width">Border Width (px):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="targets.border_width.hint">How many pixels from the screen edge to sample for LED colors (1-100)</small>
|
||||
<input type="number" id="target-editor-border-width" min="1" max="100" value="10">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="target-editor-interpolation" data-i18n="targets.interpolation">Interpolation Mode:</label>
|
||||
@@ -349,7 +344,7 @@
|
||||
</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="targets.standby_interval.hint">How often to resend the last frame when the screen is static, to keep WLED in live mode (0.5-5.0s)</small>
|
||||
<small class="input-hint" style="display:none" data-i18n="targets.standby_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small>
|
||||
<input type="range" id="target-editor-standby-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-standby-interval-value').textContent = this.value">
|
||||
</div>
|
||||
|
||||
@@ -516,7 +511,7 @@
|
||||
<div id="api-key-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 data-i18n="auth.title">🔑 Login to WLED Controller</h2>
|
||||
<h2 data-i18n="auth.title">🔑 Login to LED Grab</h2>
|
||||
<button class="modal-close-btn" id="modal-close-x-btn" onclick="closeApiKeyModal()" title="Close">✕</button>
|
||||
</div>
|
||||
<form id="api-key-form" onsubmit="submitApiKey(event)">
|
||||
@@ -579,12 +574,26 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="add-device-form">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="device-type" data-i18n="device.type">Device Type:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.type.hint">Select the type of LED controller</small>
|
||||
<select id="device-type">
|
||||
<option value="wled">WLED</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="device-name" data-i18n="device.name">Device Name:</label>
|
||||
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="device-url" data-i18n="device.url">WLED URL:</label>
|
||||
<div class="label-row">
|
||||
<label for="device-url" data-i18n="device.url">URL:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.url.hint">IP address or hostname of the device (e.g. http://192.168.1.100)</small>
|
||||
<input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
||||
</div>
|
||||
<div id="add-device-error" class="error-message" style="display: none;"></div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"auth.login": "Login",
|
||||
"auth.logout": "Logout",
|
||||
"auth.authenticated": "● Authenticated",
|
||||
"auth.title": "Login to WLED Controller",
|
||||
"auth.title": "Login to LED Grab",
|
||||
"auth.message": "Please enter your API key to authenticate and access the LED Grab.",
|
||||
"auth.label": "API Key:",
|
||||
"auth.placeholder": "Enter your API key...",
|
||||
@@ -101,13 +101,16 @@
|
||||
"devices.wled_webui_link": "WLED Web UI",
|
||||
"devices.wled_note_webui": "(open your device's IP in a browser).",
|
||||
"devices.wled_note2": "This controller sends pixel color data and controls brightness per device.",
|
||||
"device.type": "Device Type:",
|
||||
"device.type.hint": "Select the type of LED controller",
|
||||
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
|
||||
"device.name": "Device Name:",
|
||||
"device.name.placeholder": "Living Room TV",
|
||||
"device.url": "URL:",
|
||||
"device.url.placeholder": "http://192.168.1.100",
|
||||
"device.led_count": "LED Count:",
|
||||
"device.led_count.hint": "Number of LEDs configured in your WLED device",
|
||||
"device.led_count.hint.auto": "Auto-detected from WLED device",
|
||||
"device.led_count.hint": "Number of LEDs configured in the device",
|
||||
"device.led_count.hint.auto": "Auto-detected from device",
|
||||
"device.button.add": "Add Device",
|
||||
"device.button.start": "Start",
|
||||
"device.button.stop": "Stop",
|
||||
@@ -115,7 +118,7 @@
|
||||
"device.button.capture_settings": "Capture Settings",
|
||||
"device.button.calibrate": "Calibrate",
|
||||
"device.button.remove": "Remove",
|
||||
"device.button.webui": "Open WLED Web UI",
|
||||
"device.button.webui": "Open Device Web UI",
|
||||
"device.status.connected": "Connected",
|
||||
"device.status.disconnected": "Disconnected",
|
||||
"device.status.error": "Error",
|
||||
@@ -136,26 +139,26 @@
|
||||
"device.metrics.frames_skipped": "Skipped",
|
||||
"device.metrics.keepalive": "Keepalive",
|
||||
"device.metrics.errors": "Errors",
|
||||
"device.health.online": "WLED Online",
|
||||
"device.health.offline": "WLED Offline",
|
||||
"device.health.online": "Online",
|
||||
"device.health.offline": "Offline",
|
||||
"device.health.checking": "Checking...",
|
||||
"device.tutorial.start": "Start tutorial",
|
||||
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from WLED",
|
||||
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device",
|
||||
"device.tip.brightness": "Slide to adjust device brightness",
|
||||
"device.tip.start": "Start or stop screen capture processing",
|
||||
"device.tip.settings": "Configure general device settings (name, URL, health check)",
|
||||
"device.tip.capture_settings": "Configure capture settings (display, capture template)",
|
||||
"device.tip.calibrate": "Calibrate LED positions, direction, and coverage",
|
||||
"device.tip.webui": "Open WLED's built-in web interface for advanced configuration",
|
||||
"device.tip.add": "Click here to add a new WLED device",
|
||||
"device.tip.webui": "Open the device's built-in web interface for advanced configuration",
|
||||
"device.tip.add": "Click here to add a new LED device",
|
||||
"settings.title": "Device Settings",
|
||||
"settings.general.title": "General Settings",
|
||||
"settings.capture.title": "Capture Settings",
|
||||
"settings.capture.saved": "Capture settings updated",
|
||||
"settings.capture.failed": "Failed to save capture settings",
|
||||
"settings.brightness": "Brightness:",
|
||||
"settings.brightness.hint": "Global brightness for this WLED device (0-100%)",
|
||||
"settings.url.hint": "IP address or hostname of your WLED device",
|
||||
"settings.brightness.hint": "Global brightness for this device (0-100%)",
|
||||
"settings.url.hint": "IP address or hostname of the device",
|
||||
"settings.display_index": "Display:",
|
||||
"settings.display_index.hint": "Which screen to capture for this device",
|
||||
"settings.fps": "Target FPS:",
|
||||
@@ -164,7 +167,7 @@
|
||||
"settings.capture_template.hint": "Screen capture engine and configuration for this device",
|
||||
"settings.button.cancel": "Cancel",
|
||||
"settings.health_interval": "Health Check Interval (s):",
|
||||
"settings.health_interval.hint": "How often to check the WLED device status (5-600 seconds)",
|
||||
"settings.health_interval.hint": "How often to check the device status (5-600 seconds)",
|
||||
"settings.button.save": "Save Changes",
|
||||
"settings.saved": "Settings saved successfully",
|
||||
"settings.failed": "Failed to save settings",
|
||||
@@ -176,7 +179,9 @@
|
||||
"calibration.tip.span": "Drag green bars to adjust coverage span",
|
||||
"calibration.tip.test": "Click an edge to toggle test LEDs",
|
||||
"calibration.tip.toggle_inputs": "Click total LED count to toggle edge inputs",
|
||||
"calibration.tip.skip_leds": "Skip LEDs at the start or end of the strip — skipped LEDs stay off",
|
||||
"calibration.tip.border_width": "How many pixels from the screen edge to sample for LED colors",
|
||||
"calibration.tip.skip_leds_start": "Skip LEDs at the start of the strip — skipped LEDs stay off",
|
||||
"calibration.tip.skip_leds_end": "Skip LEDs at the end of the strip — skipped LEDs stay off",
|
||||
"calibration.tutorial.start": "Start tutorial",
|
||||
"calibration.start_position": "Starting Position:",
|
||||
"calibration.position.bottom_left": "Bottom Left",
|
||||
@@ -196,6 +201,8 @@
|
||||
"calibration.skip_start.hint": "Number of LEDs to turn off at the beginning of the strip (0 = none)",
|
||||
"calibration.skip_end": "Skip LEDs (End):",
|
||||
"calibration.skip_end.hint": "Number of LEDs to turn off at the end of the strip (0 = none)",
|
||||
"calibration.border_width": "Border (px):",
|
||||
"calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
|
||||
"calibration.button.cancel": "Cancel",
|
||||
"calibration.button.save": "Save",
|
||||
"calibration.saved": "Calibration saved successfully",
|
||||
@@ -313,7 +320,8 @@
|
||||
"streams.validate_image.invalid": "Image not accessible",
|
||||
"targets.title": "⚡ Targets",
|
||||
"targets.description": "Targets bridge picture sources to output devices. Each target references a device and a source, with its own processing settings.",
|
||||
"targets.subtab.wled": "WLED",
|
||||
"targets.subtab.wled": "LED",
|
||||
"targets.subtab.led": "LED",
|
||||
"targets.section.devices": "💡 Devices",
|
||||
"targets.section.targets": "⚡ Targets",
|
||||
"targets.add": "Add Target",
|
||||
@@ -324,7 +332,7 @@
|
||||
"targets.name": "Target Name:",
|
||||
"targets.name.placeholder": "My Target",
|
||||
"targets.device": "Device:",
|
||||
"targets.device.hint": "Which WLED device to send LED data to",
|
||||
"targets.device.hint": "Select the LED device to send data to",
|
||||
"targets.device.none": "-- Select a device --",
|
||||
"targets.source": "Source:",
|
||||
"targets.source.hint": "Which picture source to capture and process",
|
||||
@@ -341,7 +349,7 @@
|
||||
"targets.smoothing": "Smoothing:",
|
||||
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
||||
"targets.standby_interval": "Standby Interval:",
|
||||
"targets.standby_interval.hint": "How often to resend the last frame when the screen is static, keeping WLED in live mode (0.5-5.0s)",
|
||||
"targets.standby_interval.hint": "How often to resend the last frame when the screen is static, keeping the device in live mode (0.5-5.0s)",
|
||||
"targets.created": "Target created successfully",
|
||||
"targets.updated": "Target updated successfully",
|
||||
"targets.deleted": "Target deleted successfully",
|
||||
|
||||
@@ -101,13 +101,16 @@
|
||||
"devices.wled_webui_link": "веб-интерфейс WLED",
|
||||
"devices.wled_note_webui": "(откройте IP устройства в браузере).",
|
||||
"devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.",
|
||||
"device.type": "Тип устройства:",
|
||||
"device.type.hint": "Выберите тип LED контроллера",
|
||||
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
|
||||
"device.name": "Имя Устройства:",
|
||||
"device.name.placeholder": "ТВ в Гостиной",
|
||||
"device.url": "URL:",
|
||||
"device.url.placeholder": "http://192.168.1.100",
|
||||
"device.led_count": "Количество Светодиодов:",
|
||||
"device.led_count.hint": "Количество светодиодов, настроенных в вашем WLED устройстве",
|
||||
"device.led_count.hint.auto": "Автоматически определяется из WLED устройства",
|
||||
"device.led_count.hint": "Количество светодиодов, настроенных в устройстве",
|
||||
"device.led_count.hint.auto": "Автоматически определяется из устройства",
|
||||
"device.button.add": "Добавить Устройство",
|
||||
"device.button.start": "Запустить",
|
||||
"device.button.stop": "Остановить",
|
||||
@@ -115,7 +118,7 @@
|
||||
"device.button.capture_settings": "Настройки захвата",
|
||||
"device.button.calibrate": "Калибровка",
|
||||
"device.button.remove": "Удалить",
|
||||
"device.button.webui": "Открыть веб-интерфейс WLED",
|
||||
"device.button.webui": "Открыть веб-интерфейс устройства",
|
||||
"device.status.connected": "Подключено",
|
||||
"device.status.disconnected": "Отключено",
|
||||
"device.status.error": "Ошибка",
|
||||
@@ -136,26 +139,26 @@
|
||||
"device.metrics.frames_skipped": "Пропущено",
|
||||
"device.metrics.keepalive": "Keepalive",
|
||||
"device.metrics.errors": "Ошибки",
|
||||
"device.health.online": "WLED Онлайн",
|
||||
"device.health.offline": "WLED Недоступен",
|
||||
"device.health.online": "Онлайн",
|
||||
"device.health.offline": "Недоступен",
|
||||
"device.health.checking": "Проверка...",
|
||||
"device.tutorial.start": "Начать обучение",
|
||||
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически из WLED",
|
||||
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
|
||||
"device.tip.brightness": "Перетащите для регулировки яркости",
|
||||
"device.tip.start": "Запуск или остановка захвата экрана",
|
||||
"device.tip.settings": "Основные настройки устройства (имя, URL, интервал проверки)",
|
||||
"device.tip.capture_settings": "Настройки захвата (дисплей, шаблон захвата)",
|
||||
"device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия",
|
||||
"device.tip.webui": "Открыть встроенный веб-интерфейс WLED для расширенной настройки",
|
||||
"device.tip.add": "Нажмите, чтобы добавить новое WLED устройство",
|
||||
"device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки",
|
||||
"device.tip.add": "Нажмите, чтобы добавить новое LED устройство",
|
||||
"settings.title": "Настройки Устройства",
|
||||
"settings.general.title": "Основные Настройки",
|
||||
"settings.capture.title": "Настройки Захвата",
|
||||
"settings.capture.saved": "Настройки захвата обновлены",
|
||||
"settings.capture.failed": "Не удалось сохранить настройки захвата",
|
||||
"settings.brightness": "Яркость:",
|
||||
"settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)",
|
||||
"settings.url.hint": "IP адрес или имя хоста вашего WLED устройства",
|
||||
"settings.brightness.hint": "Общая яркость для этого устройства (0-100%)",
|
||||
"settings.url.hint": "IP адрес или имя хоста устройства",
|
||||
"settings.display_index": "Дисплей:",
|
||||
"settings.display_index.hint": "Какой экран захватывать для этого устройства",
|
||||
"settings.fps": "Целевой FPS:",
|
||||
@@ -164,7 +167,7 @@
|
||||
"settings.capture_template.hint": "Движок захвата экрана и конфигурация для этого устройства",
|
||||
"settings.button.cancel": "Отмена",
|
||||
"settings.health_interval": "Интервал Проверки (с):",
|
||||
"settings.health_interval.hint": "Как часто проверять статус WLED устройства (5-600 секунд)",
|
||||
"settings.health_interval.hint": "Как часто проверять статус устройства (5-600 секунд)",
|
||||
"settings.button.save": "Сохранить Изменения",
|
||||
"settings.saved": "Настройки успешно сохранены",
|
||||
"settings.failed": "Не удалось сохранить настройки",
|
||||
@@ -176,7 +179,9 @@
|
||||
"calibration.tip.span": "Перетащите зелёные полосы для настройки зоны покрытия",
|
||||
"calibration.tip.test": "Нажмите на край для теста LED",
|
||||
"calibration.tip.toggle_inputs": "Нажмите на общее количество LED для скрытия боковых полей",
|
||||
"calibration.tip.skip_leds": "Пропуск LED в начале или конце ленты — пропущенные LED остаются выключенными",
|
||||
"calibration.tip.border_width": "Сколько пикселей от края экрана использовать для цветов LED",
|
||||
"calibration.tip.skip_leds_start": "Пропуск LED в начале ленты — пропущенные LED остаются выключенными",
|
||||
"calibration.tip.skip_leds_end": "Пропуск LED в конце ленты — пропущенные LED остаются выключенными",
|
||||
"calibration.tutorial.start": "Начать обучение",
|
||||
"calibration.start_position": "Начальная Позиция:",
|
||||
"calibration.position.bottom_left": "Нижний Левый",
|
||||
@@ -196,6 +201,8 @@
|
||||
"calibration.skip_start.hint": "Количество LED, которые будут выключены в начале ленты (0 = нет)",
|
||||
"calibration.skip_end": "Пропуск LED (конец):",
|
||||
"calibration.skip_end.hint": "Количество LED, которые будут выключены в конце ленты (0 = нет)",
|
||||
"calibration.border_width": "Граница (px):",
|
||||
"calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
|
||||
"calibration.button.cancel": "Отмена",
|
||||
"calibration.button.save": "Сохранить",
|
||||
"calibration.saved": "Калибровка успешно сохранена",
|
||||
@@ -313,7 +320,8 @@
|
||||
"streams.validate_image.invalid": "Изображение недоступно",
|
||||
"targets.title": "⚡ Цели",
|
||||
"targets.description": "Цели связывают источники изображений с устройствами вывода. Каждая цель ссылается на устройство и источник, с собственными настройками обработки.",
|
||||
"targets.subtab.wled": "WLED",
|
||||
"targets.subtab.wled": "LED",
|
||||
"targets.subtab.led": "LED",
|
||||
"targets.section.devices": "💡 Устройства",
|
||||
"targets.section.targets": "⚡ Цели",
|
||||
"targets.add": "Добавить Цель",
|
||||
@@ -324,7 +332,7 @@
|
||||
"targets.name": "Имя Цели:",
|
||||
"targets.name.placeholder": "Моя Цель",
|
||||
"targets.device": "Устройство:",
|
||||
"targets.device.hint": "На какое WLED устройство отправлять данные LED",
|
||||
"targets.device.hint": "Выберите LED устройство для передачи данных",
|
||||
"targets.device.none": "-- Выберите устройство --",
|
||||
"targets.source": "Источник:",
|
||||
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
|
||||
@@ -341,7 +349,7 @@
|
||||
"targets.smoothing": "Сглаживание:",
|
||||
"targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
|
||||
"targets.standby_interval": "Интервал ожидания:",
|
||||
"targets.standby_interval.hint": "Как часто повторно отправлять последний кадр при статичном экране для удержания WLED в режиме live (0.5-5.0с)",
|
||||
"targets.standby_interval.hint": "Как часто повторно отправлять последний кадр при статичном экране для удержания устройства в режиме live (0.5-5.0с)",
|
||||
"targets.created": "Цель успешно создана",
|
||||
"targets.updated": "Цель успешно обновлена",
|
||||
"targets.deleted": "Цель успешно удалена",
|
||||
|
||||
@@ -343,6 +343,15 @@ section {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.device-type-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.channel-indicator {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
@@ -680,7 +689,14 @@ select {
|
||||
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;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
input[type="number"]:disabled,
|
||||
input[type="password"]:disabled,
|
||||
select:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
@@ -1167,6 +1183,35 @@ input:-webkit-autofill:focus {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.preview-screen-border-width {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-screen-border-width label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-screen-border-width input {
|
||||
width: 52px;
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-screen-border-width input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.preview-screen-total {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
Reference in New Issue
Block a user