diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index ab63593..6140696 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -51,6 +51,7 @@ def _css_to_response(source) -> ColorStripSourceResponse: gamma=getattr(source, "gamma", None), smoothing=getattr(source, "smoothing", None), interpolation_mode=getattr(source, "interpolation_mode", None), + led_count=getattr(source, "led_count", 0), calibration=calibration, description=source.description, created_at=source.created_at, @@ -93,6 +94,7 @@ async def create_color_strip_source( gamma=data.gamma, smoothing=data.smoothing, interpolation_mode=data.interpolation_mode, + led_count=data.led_count, calibration=calibration, description=data.description, ) @@ -143,6 +145,7 @@ async def update_color_strip_source( gamma=data.gamma, smoothing=data.smoothing, interpolation_mode=data.interpolation_mode, + led_count=data.led_count, calibration=calibration, description=data.description, ) diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index 2bf141f..7afb5ce 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -20,6 +20,7 @@ class ColorStripSourceCreate(BaseModel): gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0) smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0) interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)") + led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration)", ge=0) calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)") description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -35,6 +36,7 @@ class ColorStripSourceUpdate(BaseModel): gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0) smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0) interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)") + led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration)", ge=0) calibration: Optional[Calibration] = Field(None, description="LED calibration") description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -52,6 +54,7 @@ class ColorStripSourceResponse(BaseModel): gamma: Optional[float] = Field(None, description="Gamma correction") smoothing: Optional[float] = Field(None, description="Temporal smoothing") interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") + led_count: int = Field(0, description="Total LED count (0 = auto from calibration)") calibration: Optional[Calibration] = Field(None, description="LED calibration") description: Optional[str] = Field(None, description="Description") created_at: datetime = Field(description="Creation timestamp") diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py index 90fec69..4814ea7 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -142,7 +142,8 @@ class PictureColorStripStream(ColorStripStream): self._pixel_mapper = PixelMapper( self._calibration, interpolation_mode=self._interpolation_mode ) - self._led_count: int = self._calibration.get_total_leds() + cal_leds = self._calibration.get_total_leds() + self._led_count: int = source.led_count if source.led_count > 0 else cal_leds self._gamma_lut: np.ndarray = _build_gamma_lut(self._gamma) # Thread-safe color cache @@ -224,10 +225,12 @@ class PictureColorStripStream(ColorStripStream): if ( source.interpolation_mode != self._interpolation_mode or source.calibration != self._calibration + or source.led_count != self._led_count ): self._interpolation_mode = source.interpolation_mode self._calibration = source.calibration - self._led_count = source.calibration.get_total_leds() + cal_leds = source.calibration.get_total_leds() + self._led_count = source.led_count if source.led_count > 0 else cal_leds self._pixel_mapper = PixelMapper( source.calibration, interpolation_mode=source.interpolation_mode ) @@ -263,6 +266,15 @@ class PictureColorStripStream(ColorStripStream): led_colors = self._pixel_mapper.map_border_to_leds(border_pixels) t2 = time.perf_counter() + # Pad or truncate to match the declared led_count + target_count = self._led_count + if target_count > 0 and len(led_colors) != target_count: + if len(led_colors) < target_count: + pad = np.zeros((target_count - len(led_colors), 3), dtype=np.uint8) + led_colors = np.concatenate([led_colors, pad]) + else: + led_colors = led_colors[:target_count] + # Temporal smoothing smoothing = self._smoothing if ( diff --git a/server/src/wled_controller/static/css/calibration.css b/server/src/wled_controller/static/css/calibration.css index ba68c05..6a55be1 100644 --- a/server/src/wled_controller/static/css/calibration.css +++ b/server/src/wled_controller/static/css/calibration.css @@ -83,6 +83,7 @@ color: #FFC107; } + .inputs-dimmed .edge-led-input { opacity: 0.2; pointer-events: none; diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 85a06b4..ab03a92 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -221,6 +221,47 @@ color: var(--text-primary); } +.form-collapse { + margin-bottom: 12px; + border-top: 1px solid var(--border-color); + padding-top: 8px; +} + +.form-collapse > summary { + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9rem; + font-weight: 500; + color: var(--text-secondary, #888); + padding: 4px 0; + user-select: none; +} + +.form-collapse > summary::-webkit-details-marker { display: none; } + +.form-collapse > summary::before { + content: '▶'; + font-size: 0.6rem; + opacity: 0.6; + transition: transform 0.15s; + flex-shrink: 0; +} + +.form-collapse[open] > summary::before { + transform: rotate(90deg); +} + +.form-collapse > summary:hover { + color: var(--text-color); +} + +.form-collapse-body { + padding-top: 8px; +} + .error-message { background: rgba(244, 67, 54, 0.1); border: 1px solid var(--danger-color); diff --git a/server/src/wled_controller/static/js/features/calibration.js b/server/src/wled_controller/static/js/features/calibration.js index 8653142..199be5c 100644 --- a/server/src/wled_controller/static/js/features/calibration.js +++ b/server/src/wled_controller/static/js/features/calibration.js @@ -30,6 +30,7 @@ class CalibrationModal extends Modal { skip_start: this.$('cal-skip-start').value, skip_end: this.$('cal-skip-end').value, border_width: this.$('cal-border-width').value, + led_count: this.$('cal-css-led-count').value, }; } @@ -112,6 +113,7 @@ export async function showCalibration(deviceId) { document.getElementById('calibration-device-id').value = device.id; document.getElementById('cal-device-led-count-inline').textContent = device.led_count; + document.getElementById('cal-css-led-count-group').style.display = 'none'; document.getElementById('cal-start-position').value = calibration.start_position; document.getElementById('cal-layout').value = calibration.layout; @@ -216,6 +218,11 @@ export async function showCSSCalibration(cssId) { const preview = document.querySelector('.calibration-preview'); preview.style.aspectRatio = ''; document.getElementById('cal-device-led-count-inline').textContent = '—'; + const ledCountGroup = document.getElementById('cal-css-led-count-group'); + ledCountGroup.style.display = ''; + const calLeds = (calibration.leds_top || 0) + (calibration.leds_right || 0) + + (calibration.leds_bottom || 0) + (calibration.leds_left || 0); + document.getElementById('cal-css-led-count').value = source.led_count || calLeds || 0; document.getElementById('cal-start-position').value = calibration.start_position || 'bottom_left'; document.getElementById('cal-layout').value = calibration.layout || 'clockwise'; @@ -286,8 +293,17 @@ export function updateCalibrationPreview() { parseInt(document.getElementById('cal-left-leds').value || 0); const totalEl = document.querySelector('.preview-screen-total'); const inCSS = _isCSS(); - const deviceCount = inCSS ? null : parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0); - const mismatch = !inCSS && total !== deviceCount; + const declaredCount = inCSS + ? parseInt(document.getElementById('cal-css-led-count').value || 0) + : parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0); + if (inCSS) { + document.getElementById('cal-device-led-count-inline').textContent = declaredCount || '—'; + } + // In device mode: calibration total must exactly equal device LED count + // In CSS mode: warn only if calibrated LEDs exceed the declared total (padding handles the rest) + const mismatch = inCSS + ? (declaredCount > 0 && total > declaredCount) + : (total !== declaredCount); document.getElementById('cal-total-leds-inline').textContent = (mismatch ? '\u26A0 ' : '') + total; if (totalEl) totalEl.classList.toggle('mismatch', mismatch); @@ -831,10 +847,18 @@ export async function saveCalibration() { const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0); const total = topLeds + rightLeds + bottomLeds + leftLeds; + const declaredLedCount = cssMode + ? parseInt(document.getElementById('cal-css-led-count').value) || 0 + : parseInt(document.getElementById('cal-device-led-count-inline').textContent) || 0; if (!cssMode) { - const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent); - if (total !== deviceLedCount) { - error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`; + if (total !== declaredLedCount) { + error.textContent = `Total LEDs (${total}) must equal device LED count (${declaredLedCount})`; + error.style.display = 'block'; + return; + } + } else { + if (declaredLedCount > 0 && total > declaredLedCount) { + error.textContent = `Calibrated LEDs (${total}) exceed total LED count (${declaredLedCount})`; error.style.display = 'block'; return; } @@ -862,7 +886,7 @@ export async function saveCalibration() { if (cssMode) { response = await fetchWithAuth(`/color-strip-sources/${cssId}`, { method: 'PUT', - body: JSON.stringify({ calibration }), + body: JSON.stringify({ calibration, led_count: declaredLedCount }), }); } else { response = await fetchWithAuth(`/devices/${deviceId}/calibration`, { diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index ab8c637..949f5bd 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -22,6 +22,7 @@ class CSSEditorModal extends Modal { brightness: document.getElementById('css-editor-brightness').value, saturation: document.getElementById('css-editor-saturation').value, gamma: document.getElementById('css-editor-gamma').value, + led_count: document.getElementById('css-editor-led-count').value, }; } } @@ -35,7 +36,8 @@ export function createColorStripCard(source, pictureSourceMap) { ? pictureSourceMap[source.picture_source_id].name : source.picture_source_id || '—'; const cal = source.calibration || {}; - const ledCount = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0); + const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0); + const ledCount = (source.led_count > 0) ? source.led_count : calLeds; return `