Add WLED brightness control, auto-name dialogs, and UI fixes
- Brightness slider now reads/writes actual WLED device brightness via
new GET/PUT /devices/{id}/brightness endpoints (0-255 range)
- Cache last-known brightness to prevent slider jumping on card refresh
- Auto-generate names for stream, engine template, and PP template dialogs
- Fix sub-tabs not re-rendering on language change
- Add capture template icon (camera) to stream card pills
- Fix last calibration tutorial popup position
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -667,6 +667,60 @@ async def update_settings(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ===== WLED BRIGHTNESS ENDPOINT =====
|
||||
|
||||
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
|
||||
async def get_device_brightness(
|
||||
device_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
):
|
||||
"""Get current brightness from the WLED device."""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.get(f"{device.url}/json/state")
|
||||
resp.raise_for_status()
|
||||
state = resp.json()
|
||||
bri = state.get("bri", 255)
|
||||
return {"brightness": bri}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get WLED brightness for {device_id}: {e}")
|
||||
raise HTTPException(status_code=502, detail=f"Failed to reach WLED device: {e}")
|
||||
|
||||
|
||||
@router.put("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
|
||||
async def set_device_brightness(
|
||||
device_id: str,
|
||||
body: dict,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
):
|
||||
"""Set brightness on the WLED device directly."""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
|
||||
bri = body.get("brightness")
|
||||
if bri is None or not isinstance(bri, int) or not 0 <= bri <= 255:
|
||||
raise HTTPException(status_code=400, detail="brightness must be an integer 0-255")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.post(
|
||||
f"{device.url}/json/state",
|
||||
json={"bri": bri},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return {"brightness": bri}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set WLED brightness for {device_id}: {e}")
|
||||
raise HTTPException(status_code=502, detail=f"Failed to reach WLED device: {e}")
|
||||
|
||||
|
||||
# ===== CALIBRATION ENDPOINTS =====
|
||||
|
||||
@router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
|
||||
|
||||
@@ -219,9 +219,40 @@ function formatDisplayLabel(displayIndex, display) {
|
||||
return `Display ${displayIndex}`;
|
||||
}
|
||||
|
||||
let _streamNameManuallyEdited = false;
|
||||
|
||||
function onStreamDisplaySelected(displayIndex, display) {
|
||||
document.getElementById('stream-display-index').value = displayIndex;
|
||||
document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
|
||||
_autoGenerateStreamName();
|
||||
}
|
||||
|
||||
let _streamModalPPTemplates = [];
|
||||
|
||||
function _autoGenerateStreamName() {
|
||||
if (_streamNameManuallyEdited) return;
|
||||
if (document.getElementById('stream-id').value) return; // editing, not creating
|
||||
const streamType = document.getElementById('stream-type').value;
|
||||
const nameInput = document.getElementById('stream-name');
|
||||
|
||||
if (streamType === 'raw') {
|
||||
const displayIndex = document.getElementById('stream-display-index').value;
|
||||
const templateSelect = document.getElementById('stream-capture-template');
|
||||
const templateName = templateSelect.selectedOptions[0]?.dataset?.name || '';
|
||||
if (displayIndex === '' || !templateName) return;
|
||||
nameInput.value = `D${displayIndex}_${templateName}`;
|
||||
} else if (streamType === 'processed') {
|
||||
const sourceSelect = document.getElementById('stream-source');
|
||||
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
|
||||
const ppTemplateId = document.getElementById('stream-pp-template').value;
|
||||
const ppTemplate = _streamModalPPTemplates.find(t => t.id === ppTemplateId);
|
||||
if (!sourceName) return;
|
||||
if (ppTemplate && ppTemplate.name) {
|
||||
nameInput.value = `${sourceName} (${ppTemplate.name})`;
|
||||
} else {
|
||||
nameInput.value = sourceName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onTestDisplaySelected(displayIndex, display) {
|
||||
@@ -352,6 +383,7 @@ function updateAllText() {
|
||||
if (apiKey) {
|
||||
loadDisplays();
|
||||
loadDevices();
|
||||
loadPictureStreams();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,9 +676,10 @@ async function loadDevices() {
|
||||
webuiLink.rel = 'noopener';
|
||||
}
|
||||
|
||||
// Attach event listeners
|
||||
// Attach event listeners and fetch real WLED brightness
|
||||
devicesWithState.forEach(device => {
|
||||
attachDeviceListeners(device.id);
|
||||
fetchDeviceBrightness(device.id);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load devices:', error);
|
||||
@@ -661,7 +694,6 @@ function createDeviceCard(device) {
|
||||
const settings = device.settings || {};
|
||||
|
||||
const isProcessing = state.processing || false;
|
||||
const brightnessPercent = Math.round((settings.brightness !== undefined ? settings.brightness : 1.0) * 100);
|
||||
const statusKey = isProcessing ? 'device.status.processing' : 'device.status.idle';
|
||||
const status = isProcessing ? 'processing' : 'idle';
|
||||
|
||||
@@ -733,11 +765,11 @@ function createDeviceCard(device) {
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="brightness-control">
|
||||
<input type="range" class="brightness-slider" min="0" max="100"
|
||||
value="${brightnessPercent}"
|
||||
<input type="range" class="brightness-slider" min="0" max="255"
|
||||
value="${_deviceBrightnessCache[device.id] ?? 128}" data-device-brightness="${device.id}"
|
||||
oninput="updateBrightnessLabel('${device.id}', this.value)"
|
||||
onchange="saveCardBrightness('${device.id}', this.value)"
|
||||
title="${brightnessPercent}%">
|
||||
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}">
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
${isProcessing ? `
|
||||
@@ -1163,19 +1195,23 @@ async function saveCaptureSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// Card brightness controls
|
||||
// Brightness cache: stores last known WLED brightness per device (0-255)
|
||||
const _deviceBrightnessCache = {};
|
||||
|
||||
// Card brightness controls — talks directly to WLED device
|
||||
function updateBrightnessLabel(deviceId, value) {
|
||||
const slider = document.querySelector(`[data-device-id="${deviceId}"] .brightness-slider`);
|
||||
if (slider) slider.title = value + '%';
|
||||
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
|
||||
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
|
||||
}
|
||||
|
||||
async function saveCardBrightness(deviceId, value) {
|
||||
const brightness = parseInt(value) / 100.0;
|
||||
const bri = parseInt(value);
|
||||
_deviceBrightnessCache[deviceId] = bri;
|
||||
try {
|
||||
await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
||||
await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ brightness })
|
||||
body: JSON.stringify({ brightness: bri })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to update brightness:', err);
|
||||
@@ -1183,6 +1219,24 @@ async function saveCardBrightness(deviceId, value) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDeviceBrightness(deviceId) {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
_deviceBrightnessCache[deviceId] = data.brightness;
|
||||
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
|
||||
if (slider) {
|
||||
slider.value = data.brightness;
|
||||
slider.title = Math.round(data.brightness / 255 * 100) + '%';
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently fail — device may be offline
|
||||
}
|
||||
}
|
||||
|
||||
// Add device modal
|
||||
function showAddDevice() {
|
||||
const modal = document.getElementById('add-device-modal');
|
||||
@@ -2225,7 +2279,7 @@ const calibrationTutorialSteps = [
|
||||
{ selector: '.offset-control', textKey: 'calibration.tip.offset', position: 'bottom' },
|
||||
{ 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: 'bottom' }
|
||||
{ selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' }
|
||||
];
|
||||
|
||||
const deviceTutorialSteps = [
|
||||
@@ -2467,6 +2521,8 @@ function getEngineIcon(engineType) {
|
||||
}
|
||||
|
||||
// Show add template modal
|
||||
let _templateNameManuallyEdited = false;
|
||||
|
||||
async function showAddTemplateModal() {
|
||||
currentEditingTemplateId = null;
|
||||
document.getElementById('template-modal-title').textContent = t('templates.add');
|
||||
@@ -2475,6 +2531,10 @@ async function showAddTemplateModal() {
|
||||
document.getElementById('engine-config-section').style.display = 'none';
|
||||
document.getElementById('template-error').style.display = 'none';
|
||||
|
||||
// Auto-name: reset flag and wire listener
|
||||
_templateNameManuallyEdited = false;
|
||||
document.getElementById('template-name').oninput = () => { _templateNameManuallyEdited = true; };
|
||||
|
||||
// Load available engines
|
||||
await loadAvailableEngines();
|
||||
|
||||
@@ -2738,6 +2798,11 @@ async function onEngineChange() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-name: set template name to engine name if user hasn't edited
|
||||
if (!_templateNameManuallyEdited && !document.getElementById('template-id').value) {
|
||||
document.getElementById('template-name').value = engine.name || engineType;
|
||||
}
|
||||
|
||||
// Show availability hint
|
||||
const hint = document.getElementById('engine-availability-hint');
|
||||
if (!engine.available) {
|
||||
@@ -3069,7 +3134,7 @@ function renderPictureStreamsList(streams) {
|
||||
detailsHtml = `<div class="stream-card-props">
|
||||
<span class="stream-card-prop" title="${t('streams.display')}">🖥️ ${stream.display_index ?? 0}</span>
|
||||
<span class="stream-card-prop" title="${t('streams.target_fps')}">⚡ ${stream.target_fps ?? 30}</span>
|
||||
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">${capTmplName}</span>` : ''}
|
||||
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">📷 ${capTmplName}</span>` : ''}
|
||||
</div>`;
|
||||
} else if (stream.stream_type === 'processed') {
|
||||
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
|
||||
@@ -3284,6 +3349,13 @@ async function showAddStreamModal(presetType) {
|
||||
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
|
||||
onStreamTypeChange();
|
||||
|
||||
// Auto-name: reset flag and wire listeners
|
||||
_streamNameManuallyEdited = false;
|
||||
document.getElementById('stream-name').oninput = () => { _streamNameManuallyEdited = true; };
|
||||
document.getElementById('stream-capture-template').onchange = () => _autoGenerateStreamName();
|
||||
document.getElementById('stream-source').onchange = () => _autoGenerateStreamName();
|
||||
document.getElementById('stream-pp-template').onchange = () => _autoGenerateStreamName();
|
||||
|
||||
// Populate dropdowns
|
||||
await populateStreamModalDropdowns();
|
||||
|
||||
@@ -3379,6 +3451,7 @@ async function populateStreamModalDropdowns() {
|
||||
(data.templates || []).forEach(tmpl => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = tmpl.id;
|
||||
opt.dataset.name = tmpl.name;
|
||||
opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`;
|
||||
templateSelect.appendChild(opt);
|
||||
});
|
||||
@@ -3395,6 +3468,7 @@ async function populateStreamModalDropdowns() {
|
||||
if (s.id === editingId) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.dataset.name = s.name;
|
||||
const typeLabels = { raw: '🖥️', processed: '🎨', static_image: '🖼️' };
|
||||
const typeLabel = typeLabels[s.stream_type] || '📺';
|
||||
opt.textContent = `${typeLabel} ${s.name}`;
|
||||
@@ -3403,17 +3477,22 @@ async function populateStreamModalDropdowns() {
|
||||
}
|
||||
|
||||
// PP templates
|
||||
_streamModalPPTemplates = [];
|
||||
const ppSelect = document.getElementById('stream-pp-template');
|
||||
ppSelect.innerHTML = '';
|
||||
if (ppTemplatesRes.ok) {
|
||||
const data = await ppTemplatesRes.json();
|
||||
(data.templates || []).forEach(tmpl => {
|
||||
_streamModalPPTemplates = data.templates || [];
|
||||
_streamModalPPTemplates.forEach(tmpl => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = tmpl.id;
|
||||
opt.textContent = tmpl.name;
|
||||
ppSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger auto-name after dropdowns are populated
|
||||
_autoGenerateStreamName();
|
||||
}
|
||||
|
||||
async function saveStream() {
|
||||
@@ -3854,6 +3933,7 @@ function addFilterFromSelect() {
|
||||
_modalFilters.push({ filter_id: filterId, options, _expanded: true });
|
||||
select.value = '';
|
||||
renderModalFilterList();
|
||||
_autoGeneratePPTemplateName();
|
||||
}
|
||||
|
||||
function toggleFilterExpand(index) {
|
||||
@@ -3866,6 +3946,7 @@ function toggleFilterExpand(index) {
|
||||
function removeFilter(index) {
|
||||
_modalFilters.splice(index, 1);
|
||||
renderModalFilterList();
|
||||
_autoGeneratePPTemplateName();
|
||||
}
|
||||
|
||||
function moveFilter(index, direction) {
|
||||
@@ -3875,6 +3956,7 @@ function moveFilter(index, direction) {
|
||||
_modalFilters[index] = _modalFilters[newIndex];
|
||||
_modalFilters[newIndex] = tmp;
|
||||
renderModalFilterList();
|
||||
_autoGeneratePPTemplateName();
|
||||
}
|
||||
|
||||
function updateFilterOption(filterIndex, optionKey, value) {
|
||||
@@ -3904,6 +3986,20 @@ function collectFilters() {
|
||||
}));
|
||||
}
|
||||
|
||||
let _ppTemplateNameManuallyEdited = false;
|
||||
|
||||
function _autoGeneratePPTemplateName() {
|
||||
if (_ppTemplateNameManuallyEdited) return;
|
||||
if (document.getElementById('pp-template-id').value) return; // editing, not creating
|
||||
const nameInput = document.getElementById('pp-template-name');
|
||||
if (_modalFilters.length > 0) {
|
||||
const filterNames = _modalFilters.map(f => _getFilterName(f.filter_id)).join(' + ');
|
||||
nameInput.value = filterNames;
|
||||
} else {
|
||||
nameInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function showAddPPTemplateModal() {
|
||||
if (_availableFilters.length === 0) await loadAvailableFilters();
|
||||
|
||||
@@ -3914,6 +4010,10 @@ async function showAddPPTemplateModal() {
|
||||
|
||||
_modalFilters = [];
|
||||
|
||||
// Auto-name: reset flag and wire listener
|
||||
_ppTemplateNameManuallyEdited = false;
|
||||
document.getElementById('pp-template-name').oninput = () => { _ppTemplateNameManuallyEdited = true; };
|
||||
|
||||
_populateFilterSelect();
|
||||
renderModalFilterList();
|
||||
|
||||
@@ -4141,7 +4241,7 @@ async function updateStreamSelectorInfo(streamId) {
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop" title="${t('streams.display')}">🖥️ ${stream.display_index ?? 0}</span>
|
||||
<span class="stream-card-prop" title="${t('streams.target_fps')}">⚡ ${stream.target_fps ?? 30}</span>
|
||||
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">${capTmplName}</span>` : ''}
|
||||
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">📷 ${capTmplName}</span>` : ''}
|
||||
`;
|
||||
} else if (stream.stream_type === 'processed') {
|
||||
if ((!_cachedStreams || _cachedStreams.length === 0) && stream.source_stream_id) {
|
||||
|
||||
Reference in New Issue
Block a user