Add OpenRGB per-zone LED control with separate/combined modes and zone preview

- Zone picker UI in device add/settings modals with per-zone checkbox selection
- Combined mode: pixels distributed sequentially across zones
- Separate mode: full effect resampled independently to each zone via linear interpolation
- Per-zone LED preview in target cards: one canvas strip per zone with hover overlay labels
- Zone badges on device cards enriched with actual LED counts from OpenRGB API
- Fix stale led_count by using device_led_count discovered at connect time

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 20:35:51 +03:00
parent aafcf83896
commit 52ee4bdeb6
19 changed files with 769 additions and 55 deletions

View File

@@ -6,6 +6,7 @@ import {
_deviceBrightnessCache, updateDeviceBrightness,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice } from '../core/api.js';
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode } from './device-discovery.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
@@ -27,6 +28,8 @@ class DeviceSettingsModal extends Modal {
led_count: this.$('settings-led-count').value,
led_type: document.getElementById('settings-led-type')?.value || 'rgb',
send_latency: document.getElementById('settings-send-latency')?.value || '0',
zones: _getCheckedZones('settings-zone-list'),
zoneMode: _getZoneMode('settings-zone-mode'),
};
}
@@ -42,7 +45,16 @@ class DeviceSettingsModal extends Modal {
if (isSerialDevice(this.deviceType)) {
return this.$('settings-serial-port').value;
}
return this.$('settings-device-url').value.trim();
let url = this.$('settings-device-url').value.trim();
// Append selected zones for OpenRGB
if (isOpenrgbDevice(this.deviceType)) {
const zones = _getCheckedZones('settings-zone-list');
if (zones.length > 0) {
const { baseUrl } = _splitOpenrgbZone(url);
url = baseUrl + '/' + zones.join('+');
}
}
return url;
}
}
@@ -78,6 +90,10 @@ export function createDeviceCard(device) {
const ledCount = state.device_led_count || device.led_count;
// Parse zone names from OpenRGB URL for badge display
const openrgbZones = isOpenrgbDevice(device.device_type)
? _splitOpenrgbZone(device.url).zones : [];
return wrapCard({
dataAttr: 'data-device-id',
id: device.id,
@@ -89,13 +105,15 @@ export function createDeviceCard(device) {
<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">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
${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">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !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')}">${ICON_LED} ${ledCount}</span>` : ''}
${openrgbZones.length
? openrgbZones.map(z => `<span class="card-meta zone-badge" data-zone-name="${escapeHtml(z)}">${ICON_LED} ${escapeHtml(z)}</span>`).join('')
: (ledCount ? `<span class="card-meta" title="${t('device.led_count')}">${ICON_LED} ${ledCount}</span>` : '')}
${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${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>
@@ -207,6 +225,9 @@ export async function showSettings(deviceId) {
if (urlLabel) urlLabel.textContent = t('device.openrgb.url');
if (urlHint) urlHint.textContent = t('device.openrgb.url.hint');
urlInput.placeholder = 'openrgb://localhost:6742/0';
// Parse zone from URL and show base URL only
const { baseUrl, zones: currentZones } = _splitOpenrgbZone(device.url);
urlInput.value = baseUrl;
} else {
if (urlLabel) urlLabel.textContent = t('device.url');
if (urlHint) urlHint.textContent = t('settings.url.hint');
@@ -279,6 +300,29 @@ export async function showSettings(deviceId) {
autoShutdownGroup.style.display = caps.includes('auto_restore') ? '' : 'none';
}
document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown;
// OpenRGB zone picker + mode toggle
const settingsZoneGroup = document.getElementById('settings-zone-group');
const settingsZoneModeGroup = document.getElementById('settings-zone-mode-group');
if (settingsZoneModeGroup) settingsZoneModeGroup.style.display = 'none';
if (settingsZoneGroup) {
if (isOpenrgbDevice(device.device_type)) {
settingsZoneGroup.style.display = '';
const { baseUrl, zones: currentZones } = _splitOpenrgbZone(device.url);
// Set zone mode radio from device
const savedMode = device.zone_mode || 'combined';
const modeRadio = document.querySelector(`input[name="settings-zone-mode"][value="${savedMode}"]`);
if (modeRadio) modeRadio.checked = true;
_fetchOpenrgbZones(baseUrl, 'settings-zone-list', currentZones).then(() => {
// Re-snapshot after zones are loaded so dirty-check baseline includes them
settingsModal.snapshot();
});
} else {
settingsZoneGroup.style.display = 'none';
document.getElementById('settings-zone-list').innerHTML = '';
}
}
settingsModal.snapshot();
settingsModal.open();
@@ -327,6 +371,9 @@ export async function saveDeviceSettings() {
const ledType = document.getElementById('settings-led-type')?.value;
body.rgbw = ledType === 'rgbw';
}
if (isOpenrgbDevice(settingsModal.deviceType)) {
body.zone_mode = _getZoneMode('settings-zone-mode');
}
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
method: 'PUT',
body: JSON.stringify(body)
@@ -487,3 +534,62 @@ export function copyWsUrl() {
export async function loadDevices() {
await window.loadTargetsTab();
}
// ===== OpenRGB zone count enrichment =====
// Cache: baseUrl → { zoneName: ledCount, ... }
const _zoneCountCache = {};
/** Return cached zone LED counts for a base URL, or null if not cached. */
export function getZoneCountCache(baseUrl) {
return _zoneCountCache[baseUrl] || null;
}
const _zoneCountInFlight = new Set();
/**
* Fetch zone LED counts for an OpenRGB device and update zone badges on the card.
* Called after cards are rendered (same pattern as fetchDeviceBrightness).
*/
export async function enrichOpenrgbZoneBadges(deviceId, deviceUrl) {
const { baseUrl, zones } = _splitOpenrgbZone(deviceUrl);
if (!zones.length) return;
// Use cache if available
if (_zoneCountCache[baseUrl]) {
_applyZoneCounts(deviceId, zones, _zoneCountCache[baseUrl]);
return;
}
// Deduplicate in-flight requests per base URL
if (_zoneCountInFlight.has(baseUrl)) return;
_zoneCountInFlight.add(baseUrl);
try {
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
if (!resp.ok) return;
const data = await resp.json();
const counts = {};
for (const z of data.zones) {
counts[z.name.toLowerCase()] = z.led_count;
}
_zoneCountCache[baseUrl] = counts;
_applyZoneCounts(deviceId, zones, counts);
} catch {
// Silently fail — device may be offline
} finally {
_zoneCountInFlight.delete(baseUrl);
}
}
function _applyZoneCounts(deviceId, zones, counts) {
const card = document.querySelector(`[data-device-id="${deviceId}"]`);
if (!card) return;
for (const zoneName of zones) {
const badge = card.querySelector(`[data-zone-name="${zoneName}"]`);
if (!badge) continue;
const ledCount = counts[zoneName.toLowerCase()];
if (ledCount != null) {
badge.innerHTML = `${ICON_LED} ${escapeHtml(zoneName)} · ${ledCount}`;
}
}
}