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

@@ -25,6 +25,8 @@ class AddDeviceModal extends Modal {
baudRate: document.getElementById('device-baud-rate').value,
ledType: document.getElementById('device-led-type')?.value || 'rgb',
sendLatency: document.getElementById('device-send-latency')?.value || '0',
zones: _getCheckedZones('device-zone-list'),
zoneMode: _getZoneMode(),
};
}
}
@@ -47,8 +49,14 @@ export function onDeviceTypeChanged() {
const urlLabel = document.getElementById('device-url-label');
const urlHint = document.getElementById('device-url-hint');
const zoneGroup = document.getElementById('device-zone-group');
const scanBtn = document.getElementById('scan-network-btn');
// Hide zone group + mode group by default (shown only for openrgb)
if (zoneGroup) zoneGroup.style.display = 'none';
const zoneModeGroup = document.getElementById('device-zone-mode-group');
if (zoneModeGroup) zoneModeGroup.style.display = 'none';
if (isMqttDevice(deviceType)) {
// MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery
urlGroup.style.display = '';
@@ -121,6 +129,7 @@ export function onDeviceTypeChanged() {
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
if (scanBtn) scanBtn.style.display = '';
if (zoneGroup) zoneGroup.style.display = '';
if (urlLabel) urlLabel.textContent = t('device.openrgb.url');
if (urlHint) urlHint.textContent = t('device.openrgb.url.hint');
urlInput.placeholder = 'openrgb://localhost:6742/0';
@@ -354,6 +363,10 @@ export function selectDiscoveredDevice(device) {
} else {
document.getElementById('device-url').value = device.url;
}
// Fetch zones for OpenRGB devices
if (isOpenrgbDevice(device.device_type)) {
_fetchOpenrgbZones(device.url, 'device-zone-list');
}
showToast(t('device.scan.selected'), 'info');
}
@@ -380,12 +393,20 @@ export async function handleAddDevice(event) {
url = 'mqtt://' + url;
}
// OpenRGB: append selected zones to URL
const checkedZones = isOpenrgbDevice(deviceType) ? _getCheckedZones('device-zone-list') : [];
if (isOpenrgbDevice(deviceType) && checkedZones.length > 0) {
url = _appendZonesToUrl(url, checkedZones);
}
if (!name || (!isMockDevice(deviceType) && !isWsDevice(deviceType) && !url)) {
error.textContent = t('device_discovery.error.fill_all_fields');
error.style.display = 'block';
return;
}
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
try {
const body = { name, url, device_type: deviceType };
const ledCountInput = document.getElementById('device-led-count');
@@ -402,10 +423,10 @@ export async function handleAddDevice(event) {
const ledType = document.getElementById('device-led-type')?.value;
body.rgbw = ledType === 'rgbw';
}
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
if (lastTemplateId) {
body.capture_template_id = lastTemplateId;
if (isOpenrgbDevice(deviceType) && checkedZones.length >= 2) {
body.zone_mode = _getZoneMode();
}
if (lastTemplateId) body.capture_template_id = lastTemplateId;
const response = await fetchWithAuth('/devices', {
method: 'POST',
@@ -417,9 +438,7 @@ export async function handleAddDevice(event) {
console.log('Device added successfully:', result);
showToast(t('device_discovery.added'), 'success');
addDeviceModal.forceClose();
// Use window.* to avoid circular imports
if (typeof window.loadDevices === 'function') await window.loadDevices();
// Auto-start device tutorial on first device add
if (!localStorage.getItem('deviceTutorialSeen')) {
localStorage.setItem('deviceTutorialSeen', '1');
setTimeout(() => {
@@ -438,3 +457,112 @@ export async function handleAddDevice(event) {
showToast(t('device_discovery.error.add_failed'), 'error');
}
}
// ===== OpenRGB zone helpers =====
/**
* Fetch zones for an OpenRGB device URL and render checkboxes in the given container.
* @param {string} baseUrl - Base OpenRGB URL (e.g. openrgb://localhost:6742/0)
* @param {string} containerId - ID of the zone checkbox list container
* @param {string[]} [preChecked=[]] - Zone names to pre-check
*/
export async function _fetchOpenrgbZones(baseUrl, containerId, preChecked = []) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = `<span class="zone-loading">${t('device.openrgb.zone.loading')}</span>`;
try {
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
container.innerHTML = `<span class="zone-error">${err.detail || t('device.openrgb.zone.error')}</span>`;
return;
}
const data = await resp.json();
_renderZoneCheckboxes(container, data.zones, preChecked);
} catch (err) {
if (err.isAuth) return;
container.innerHTML = `<span class="zone-error">${t('device.openrgb.zone.error')}</span>`;
}
}
function _renderZoneCheckboxes(container, zones, preChecked = []) {
container.innerHTML = '';
container._zonesData = zones;
const preSet = new Set(preChecked.map(n => n.toLowerCase()));
zones.forEach(zone => {
const label = document.createElement('label');
label.className = 'zone-checkbox-item';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = zone.name;
if (preSet.has(zone.name.toLowerCase())) cb.checked = true;
cb.addEventListener('change', () => _updateZoneModeVisibility(container.id));
const nameSpan = document.createElement('span');
nameSpan.textContent = zone.name;
const countSpan = document.createElement('span');
countSpan.className = 'zone-led-count';
countSpan.textContent = `${zone.led_count} LEDs`;
label.appendChild(cb);
label.appendChild(nameSpan);
label.appendChild(countSpan);
container.appendChild(label);
});
_updateZoneModeVisibility(container.id);
}
export function _getCheckedZones(containerId) {
const container = document.getElementById(containerId);
if (!container) return [];
return Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
.map(cb => cb.value);
}
/**
* Split an OpenRGB URL into base URL (without zones) and zone names.
* E.g. "openrgb://localhost:6742/0/JRAINBOW1+JRAINBOW2" → { baseUrl: "openrgb://localhost:6742/0", zones: ["JRAINBOW1","JRAINBOW2"] }
*/
export function _splitOpenrgbZone(url) {
if (!url || !url.startsWith('openrgb://')) return { baseUrl: url, zones: [] };
const stripped = url.slice('openrgb://'.length);
const parts = stripped.split('/');
// parts: [host:port, device_index, ...zone_str]
if (parts.length >= 3) {
const zoneStr = parts.slice(2).join('/');
const zones = zoneStr.split('+').map(z => z.trim()).filter(Boolean);
const baseUrl = 'openrgb://' + parts[0] + '/' + parts[1];
return { baseUrl, zones };
}
return { baseUrl: url, zones: [] };
}
function _appendZonesToUrl(baseUrl, zones) {
// Strip any existing zone suffix
const { baseUrl: clean } = _splitOpenrgbZone(baseUrl);
return clean + '/' + zones.join('+');
}
/** Show/hide zone mode toggle based on how many zones are checked. */
export function _updateZoneModeVisibility(containerId) {
const modeGroupId = containerId === 'device-zone-list' ? 'device-zone-mode-group'
: containerId === 'settings-zone-list' ? 'settings-zone-mode-group'
: null;
if (!modeGroupId) return;
const modeGroup = document.getElementById(modeGroupId);
if (!modeGroup) return;
const checkedCount = _getCheckedZones(containerId).length;
modeGroup.style.display = checkedCount >= 2 ? '' : 'none';
}
/** Get the selected zone mode radio value ('combined' or 'separate'). */
export function _getZoneMode(radioName = 'device-zone-mode') {
const radio = document.querySelector(`input[name="${radioName}"]:checked`);
return radio ? radio.value : 'combined';
}