diff --git a/server/src/wled_controller/api/schemas.py b/server/src/wled_controller/api/schemas.py index 8f25811..842837e 100644 --- a/server/src/wled_controller/api/schemas.py +++ b/server/src/wled_controller/api/schemas.py @@ -110,6 +110,15 @@ class Calibration(BaseModel): leds_right: int = Field(default=0, ge=0, description="Number of LEDs on the right edge") leds_bottom: int = Field(default=0, ge=0, description="Number of LEDs on the bottom edge") leds_left: int = Field(default=0, ge=0, description="Number of LEDs on the left edge") + # Per-edge span: fraction of screen side covered by LEDs (0.0–1.0) + span_top_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of top edge coverage") + span_top_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of top edge coverage") + span_right_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of right edge coverage") + span_right_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of right edge coverage") + span_bottom_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of bottom edge coverage") + span_bottom_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of bottom edge coverage") + span_left_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of left edge coverage") + span_left_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of left edge coverage") class CalibrationTestModeRequest(BaseModel): diff --git a/server/src/wled_controller/core/calibration.py b/server/src/wled_controller/core/calibration.py index 908fcce..812b467 100644 --- a/server/src/wled_controller/core/calibration.py +++ b/server/src/wled_controller/core/calibration.py @@ -65,6 +65,15 @@ class CalibrationConfig: leds_right: int = 0 leds_bottom: int = 0 leds_left: int = 0 + # Per-edge span: fraction of screen side covered by LEDs (0.0–1.0) + span_top_start: float = 0.0 + span_top_end: float = 1.0 + span_right_start: float = 0.0 + span_right_end: float = 1.0 + span_bottom_start: float = 0.0 + span_bottom_end: float = 1.0 + span_left_start: float = 0.0 + span_left_end: float = 1.0 def build_segments(self) -> List[CalibrationSegment]: """Derive segment list from core parameters.""" @@ -99,6 +108,13 @@ class CalibrationConfig: """Get derived segment list.""" return self.build_segments() + def get_edge_span(self, edge: str) -> tuple[float, float]: + """Get span (start, end) for a given edge.""" + return ( + getattr(self, f"span_{edge}_start", 0.0), + getattr(self, f"span_{edge}_end", 1.0), + ) + def validate(self) -> bool: """Validate calibration configuration. @@ -117,6 +133,13 @@ class CalibrationConfig: if count < 0: raise ValueError(f"LED count for {edge} must be non-negative, got {count}") + for edge in ["top", "right", "bottom", "left"]: + start, end = self.get_edge_span(edge) + if not (0.0 <= start <= 1.0) or not (0.0 <= end <= 1.0): + raise ValueError(f"Span for {edge} must be in [0.0, 1.0], got ({start}, {end})") + if end <= start: + raise ValueError(f"Span end must be greater than start for {edge}, got ({start}, {end})") + return True def get_total_leds(self) -> int: @@ -202,6 +225,20 @@ class PixelMapper: else: # left edge_pixels = border_pixels.left + # Slice to span region if not full coverage + span_start, span_end = self.calibration.get_edge_span(edge_name) + if span_start > 0.0 or span_end < 1.0: + if edge_name in ("top", "bottom"): + total_w = edge_pixels.shape[1] + s = int(span_start * total_w) + e = int(span_end * total_w) + edge_pixels = edge_pixels[:, s:e, :] + else: + total_h = edge_pixels.shape[0] + s = int(span_start * total_h) + e = int(span_end * total_h) + edge_pixels = edge_pixels[s:e, :, :] + # Divide edge into segments matching LED count try: pixel_segments = get_edge_segments( @@ -333,6 +370,14 @@ def calibration_from_dict(data: dict) -> CalibrationConfig: leds_right=data.get("leds_right", 0), leds_bottom=data.get("leds_bottom", 0), leds_left=data.get("leds_left", 0), + span_top_start=data.get("span_top_start", 0.0), + span_top_end=data.get("span_top_end", 1.0), + span_right_start=data.get("span_right_start", 0.0), + span_right_end=data.get("span_right_end", 1.0), + span_bottom_start=data.get("span_bottom_start", 0.0), + span_bottom_end=data.get("span_bottom_end", 1.0), + span_left_start=data.get("span_left_start", 0.0), + span_left_end=data.get("span_left_end", 1.0), ) config.validate() @@ -355,7 +400,7 @@ def calibration_to_dict(config: CalibrationConfig) -> dict: Returns: Dictionary representation """ - return { + result = { "layout": config.layout, "start_position": config.start_position, "offset": config.offset, @@ -364,3 +409,11 @@ def calibration_to_dict(config: CalibrationConfig) -> dict: "leds_bottom": config.leds_bottom, "leds_left": config.leds_left, } + # Include span fields only when not default (full coverage) + for edge in ["top", "right", "bottom", "left"]: + start = getattr(config, f"span_{edge}_start") + end = getattr(config, f"span_{edge}_end") + if start != 0.0 or end != 1.0: + result[f"span_{edge}_start"] = start + result[f"span_{edge}_end"] = end + return result diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 49956a2..85f0bb2 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -1057,6 +1057,14 @@ async function showCalibration(deviceId) { document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0; document.getElementById('cal-left-leds').value = calibration.leds_left || 0; + // Initialize edge spans + window.edgeSpans = { + top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 }, + right: { start: calibration.span_right_start ?? 0, end: calibration.span_right_end ?? 1 }, + bottom: { start: calibration.span_bottom_start ?? 0, end: calibration.span_bottom_end ?? 1 }, + left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 }, + }; + // Snapshot initial values for dirty checking calibrationInitialValues = { start_position: calibration.start_position, @@ -1066,6 +1074,7 @@ async function showCalibration(deviceId) { right: String(calibration.leds_right || 0), bottom: String(calibration.leds_bottom || 0), left: String(calibration.leds_left || 0), + spans: JSON.stringify(window.edgeSpans), }; // Initialize test mode state for this device @@ -1079,7 +1088,8 @@ async function showCalibration(deviceId) { modal.style.display = 'flex'; lockBody(); - // Render canvas after layout settles + // Initialize span drag and render canvas after layout settles + initSpanDrag(); requestAnimationFrame(() => renderCalibrationCanvas()); } catch (error) { @@ -1096,7 +1106,8 @@ function isCalibrationDirty() { document.getElementById('cal-top-leds').value !== calibrationInitialValues.top || document.getElementById('cal-right-leds').value !== calibrationInitialValues.right || document.getElementById('cal-bottom-leds').value !== calibrationInitialValues.bottom || - document.getElementById('cal-left-leds').value !== calibrationInitialValues.left + document.getElementById('cal-left-leds').value !== calibrationInitialValues.left || + JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans ); } @@ -1176,7 +1187,8 @@ function updateCalibrationPreview() { } }); - // Render canvas overlay (ticks, arrows, start label) + // Position span bars and render canvas overlay + updateSpanBars(); renderCalibrationCanvas(); } @@ -1237,12 +1249,16 @@ function renderCalibrationCanvas() { const cw = 56; const ch = 36; - // Edge midlines (center of each edge bar) - in canvas coords + // Span-aware edge geometry: ticks/arrows render only within the span region + const spans = window.edgeSpans || {}; + const edgeLenH = cW - 2 * cw; + const edgeLenV = cH - 2 * ch; + const edgeGeometry = { - top: { x1: ox + cw, x2: ox + cW - cw, midY: oy + ch / 2, horizontal: true }, - bottom: { x1: ox + cw, x2: ox + cW - cw, midY: oy + cH - ch / 2, horizontal: true }, - left: { y1: oy + ch, y2: oy + cH - ch, midX: ox + cw / 2, horizontal: false }, - right: { y1: oy + ch, y2: oy + cH - ch, midX: ox + cW - cw / 2, horizontal: false }, + top: { x1: ox + cw + (spans.top?.start || 0) * edgeLenH, x2: ox + cw + (spans.top?.end || 1) * edgeLenH, midY: oy + ch / 2, horizontal: true }, + bottom: { x1: ox + cw + (spans.bottom?.start || 0) * edgeLenH, x2: ox + cw + (spans.bottom?.end || 1) * edgeLenH, midY: oy + cH - ch / 2, horizontal: true }, + left: { y1: oy + ch + (spans.left?.start || 0) * edgeLenV, y2: oy + ch + (spans.left?.end || 1) * edgeLenV, midX: ox + cw / 2, horizontal: false }, + right: { y1: oy + ch + (spans.right?.start || 0) * edgeLenV, y2: oy + ch + (spans.right?.end || 1) * edgeLenV, midX: ox + cW - cw / 2, horizontal: false }, }; // Axis positions for labels (outside the container bounds, in the padding area) @@ -1397,6 +1413,126 @@ function renderCalibrationCanvas() { } +function updateSpanBars() { + const spans = window.edgeSpans || {}; + ['top', 'right', 'bottom', 'left'].forEach(edge => { + const bar = document.querySelector(`.edge-span-bar[data-edge="${edge}"]`); + if (!bar) return; + const span = spans[edge] || { start: 0, end: 1 }; + const edgeEl = bar.parentElement; + const isHorizontal = (edge === 'top' || edge === 'bottom'); + + if (isHorizontal) { + const totalWidth = edgeEl.clientWidth; + bar.style.left = (span.start * totalWidth) + 'px'; + bar.style.width = ((span.end - span.start) * totalWidth) + 'px'; + } else { + const totalHeight = edgeEl.clientHeight; + bar.style.top = (span.start * totalHeight) + 'px'; + bar.style.height = ((span.end - span.start) * totalHeight) + 'px'; + } + }); +} + +function initSpanDrag() { + const MIN_SPAN = 0.05; + + document.querySelectorAll('.edge-span-bar').forEach(bar => { + const edge = bar.dataset.edge; + const isHorizontal = (edge === 'top' || edge === 'bottom'); + + // Prevent edge click-through when interacting with span bar + bar.addEventListener('click', e => e.stopPropagation()); + + // Handle resize via handles + bar.querySelectorAll('.edge-span-handle').forEach(handle => { + handle.addEventListener('mousedown', e => { + e.preventDefault(); + e.stopPropagation(); + const handleType = handle.dataset.handle; + const edgeEl = bar.parentElement; + const rect = edgeEl.getBoundingClientRect(); + + function onMouseMove(ev) { + const span = window.edgeSpans[edge]; + let fraction; + if (isHorizontal) { + fraction = (ev.clientX - rect.left) / rect.width; + } else { + fraction = (ev.clientY - rect.top) / rect.height; + } + fraction = Math.max(0, Math.min(1, fraction)); + + if (handleType === 'start') { + span.start = Math.min(fraction, span.end - MIN_SPAN); + } else { + span.end = Math.max(fraction, span.start + MIN_SPAN); + } + + updateSpanBars(); + renderCalibrationCanvas(); + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + }); + + // Handle body drag (move entire span) + bar.addEventListener('mousedown', e => { + if (e.target.classList.contains('edge-span-handle')) return; + e.preventDefault(); + e.stopPropagation(); + + const edgeEl = bar.parentElement; + const rect = edgeEl.getBoundingClientRect(); + const span = window.edgeSpans[edge]; + const spanWidth = span.end - span.start; + + let startFraction; + if (isHorizontal) { + startFraction = (e.clientX - rect.left) / rect.width; + } else { + startFraction = (e.clientY - rect.top) / rect.height; + } + const offsetInSpan = startFraction - span.start; + + function onMouseMove(ev) { + let fraction; + if (isHorizontal) { + fraction = (ev.clientX - rect.left) / rect.width; + } else { + fraction = (ev.clientY - rect.top) / rect.height; + } + + let newStart = fraction - offsetInSpan; + newStart = Math.max(0, Math.min(1 - spanWidth, newStart)); + span.start = newStart; + span.end = newStart + spanWidth; + + updateSpanBars(); + renderCalibrationCanvas(); + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + }); + + // Initial positioning + updateSpanBars(); +} + function setStartPosition(position) { document.getElementById('cal-start-position').value = position; updateCalibrationPreview(); @@ -1501,6 +1637,7 @@ async function saveCalibration() { const layout = document.getElementById('cal-layout').value; const offset = parseInt(document.getElementById('cal-offset').value || 0); + const spans = window.edgeSpans || {}; const calibration = { layout: layout, start_position: startPosition, @@ -1508,7 +1645,15 @@ async function saveCalibration() { leds_top: topLeds, leds_right: rightLeds, leds_bottom: bottomLeds, - leds_left: leftLeds + leds_left: leftLeds, + span_top_start: spans.top?.start ?? 0, + span_top_end: spans.top?.end ?? 1, + span_right_start: spans.right?.start ?? 0, + span_right_end: spans.right?.end ?? 1, + span_bottom_start: spans.bottom?.start ?? 0, + span_bottom_end: spans.bottom?.end ?? 1, + span_left_start: spans.left?.start ?? 0, + span_left_end: spans.left?.end ?? 1, }; try { diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 7bc7f9f..3dfbf69 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -102,18 +102,34 @@