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:
@@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user