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:
2026-02-16 12:41:02 +03:00
parent afce183f79
commit b5a6885126
18 changed files with 667 additions and 346 deletions

View File

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

View File

@@ -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">&#x2715;</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>

View File

@@ -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",

View File

@@ -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": "Цель успешно удалена",

View File

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