diff --git a/server/src/wled_controller/api/routes.py b/server/src/wled_controller/api/routes.py
index 05347c8..9ecc691 100644
--- a/server/src/wled_controller/api/routes.py
+++ b/server/src/wled_controller/api/routes.py
@@ -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"])
diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js
index 8f71376..8d8f05b 100644
--- a/server/src/wled_controller/static/app.js
+++ b/server/src/wled_controller/static/app.js
@@ -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) {
` : ''}
-
+ title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}">
${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 = `
🖥️ ${stream.display_index ?? 0}
⚡ ${stream.target_fps ?? 30}
- ${capTmplName ? `${capTmplName}` : ''}
+ ${capTmplName ? `📷 ${capTmplName}` : ''}
`;
} 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 = `
🖥️ ${stream.display_index ?? 0}
⚡ ${stream.target_fps ?? 30}
- ${capTmplName ? `
${capTmplName}` : ''}
+ ${capTmplName ? `
📷 ${capTmplName}` : ''}
`;
} else if (stream.stream_type === 'processed') {
if ((!_cachedStreams || _cachedStreams.length === 0) && stream.source_stream_id) {