diff --git a/INSTALLATION.md b/INSTALLATION.md index 96422e6..7e0ae45 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -39,7 +39,7 @@ Complete installation guide for WLED Screen Controller server and Home Assistant 3. **Install dependencies:** ```bash - pip install -r requirements.txt + pip install . ``` 4. **Configure (optional):** diff --git a/README.md b/README.md index 002956a..76219af 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ This project consists of two components: 2. **Install dependencies** ```bash - pip install -r requirements.txt + pip install . ``` 3. **Run the server** @@ -150,7 +150,7 @@ wled-screen-controller/ │ ├── src/wled_controller/ # Main application code │ ├── tests/ # Unit and integration tests │ ├── config/ # Configuration files -│ └── requirements.txt # Python dependencies +│ └── pyproject.toml # Python dependencies & project config ├── homeassistant/ # Home Assistant integration │ └── custom_components/ └── docs/ # Documentation diff --git a/server/Dockerfile b/server/Dockerfile index c9e4cdf..dd7d5e1 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -14,13 +14,11 @@ RUN apt-get update && apt-get install -y \ libxcb-shape0 \ && rm -rf /var/lib/apt/lists/* -# Copy requirements and install Python dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code +# Copy project files and install Python dependencies +COPY pyproject.toml . COPY src/ ./src/ COPY config/ ./config/ +RUN pip install --no-cache-dir . # Create directories for data and logs RUN mkdir -p /app/data /app/logs diff --git a/server/README.md b/server/README.md index 384fd67..b48e039 100644 --- a/server/README.md +++ b/server/README.md @@ -40,7 +40,7 @@ source venv/bin/activate # Linux/Mac venv\Scripts\activate # Windows # Install dependencies -pip install -r requirements.txt +pip install . # Set PYTHONPATH export PYTHONPATH=$(pwd)/src # Linux/Mac diff --git a/server/pyproject.toml b/server/pyproject.toml index 12e5e75..c3980c7 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -35,6 +35,8 @@ dependencies = [ "structlog>=24.4.0", "python-json-logger>=3.1.0", "python-dateutil>=2.9.0", + "python-multipart>=0.0.12", + "wmi>=1.5.1; sys_platform == 'win32'", ] [project.optional-dependencies] @@ -48,9 +50,9 @@ dev = [ ] [project.urls] -Homepage = "https://github.com/yourusername/wled-screen-controller" -Repository = "https://github.com/yourusername/wled-screen-controller" -Issues = "https://github.com/yourusername/wled-screen-controller/issues" +Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed" +Repository = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed" +Issues = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues" [tool.setuptools] package-dir = {"" = "src"} diff --git a/server/requirements.txt b/server/requirements.txt deleted file mode 100644 index 930f90d..0000000 --- a/server/requirements.txt +++ /dev/null @@ -1,34 +0,0 @@ -# Web Framework -fastapi==0.115.0 -uvicorn[standard]==0.32.0 -python-multipart==0.0.12 - -# HTTP Client -httpx==0.27.2 - -# Screen Capture -mss==9.0.2 -Pillow==10.4.0 -numpy==2.1.3 - -# Configuration -pydantic==2.9.2 -pydantic-settings==2.6.0 -PyYAML==6.0.2 - -# Logging -structlog==24.4.0 -python-json-logger==3.1.0 - -# Utilities -python-dateutil==2.9.0 - -# Windows-specific (optional for friendly monitor names) -wmi==1.5.1; sys_platform == 'win32' - -# Testing -pytest==8.3.3 -pytest-asyncio==0.24.0 -pytest-cov==6.0.0 -httpx==0.27.2 -respx==0.21.1 diff --git a/server/src/wled_controller/api/routes.py b/server/src/wled_controller/api/routes.py index d95e985..be4186a 100644 --- a/server/src/wled_controller/api/routes.py +++ b/server/src/wled_controller/api/routes.py @@ -309,7 +309,6 @@ async def update_device( device_id=device_id, name=update_data.name, url=update_data.url, - led_count=update_data.led_count, enabled=update_data.enabled, ) diff --git a/server/src/wled_controller/api/schemas.py b/server/src/wled_controller/api/schemas.py index f032f42..fa5fbbc 100644 --- a/server/src/wled_controller/api/schemas.py +++ b/server/src/wled_controller/api/schemas.py @@ -90,15 +90,6 @@ class ProcessingSettings(BaseModel): ) -class CalibrationSegment(BaseModel): - """Calibration segment for LED mapping.""" - - edge: Literal["top", "right", "bottom", "left"] = Field(description="Screen edge") - led_start: int = Field(description="Starting LED index", ge=0) - led_count: int = Field(description="Number of LEDs on this edge", gt=0) - reverse: bool = Field(default=False, description="Reverse LED order on this edge") - - class Calibration(BaseModel): """Calibration configuration for pixel-to-LED mapping.""" @@ -108,18 +99,17 @@ class Calibration(BaseModel): ) start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = Field( default="bottom_left", - description="Position of LED index 0" + description="Starting corner of the LED strip" ) offset: int = Field( default=0, ge=0, description="Number of LEDs from physical LED 0 to start corner (along strip direction)" ) - segments: List[CalibrationSegment] = Field( - description="LED segments for each screen edge", - min_length=1, - max_length=4 - ) + leds_top: int = Field(default=0, ge=0, description="Number of LEDs on the top edge") + 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") class CalibrationTestModeRequest(BaseModel): diff --git a/server/src/wled_controller/core/calibration.py b/server/src/wled_controller/core/calibration.py index df86568..908fcce 100644 --- a/server/src/wled_controller/core/calibration.py +++ b/server/src/wled_controller/core/calibration.py @@ -1,7 +1,7 @@ """Calibration system for mapping screen pixels to LED positions.""" -from dataclasses import dataclass -from typing import List, Literal, Tuple +from dataclasses import dataclass, field +from typing import Dict, List, Literal, Tuple from wled_controller.core.screen_capture import ( BorderPixels, @@ -14,6 +14,31 @@ from wled_controller.utils import get_logger logger = get_logger(__name__) +# Edge traversal order for each (start_position, layout) combination. +# Determines which edge comes first when walking around the screen. +EDGE_ORDER: Dict[Tuple[str, str], List[str]] = { + ("bottom_left", "clockwise"): ["left", "top", "right", "bottom"], + ("bottom_left", "counterclockwise"): ["bottom", "right", "top", "left"], + ("bottom_right", "clockwise"): ["bottom", "left", "top", "right"], + ("bottom_right", "counterclockwise"): ["right", "top", "left", "bottom"], + ("top_left", "clockwise"): ["top", "right", "bottom", "left"], + ("top_left", "counterclockwise"): ["left", "bottom", "right", "top"], + ("top_right", "clockwise"): ["right", "bottom", "left", "top"], + ("top_right", "counterclockwise"): ["top", "left", "bottom", "right"], +} + +# Whether LEDs are reversed on each edge for each (start_position, layout) combination. +EDGE_REVERSE: Dict[Tuple[str, str], Dict[str, bool]] = { + ("bottom_left", "clockwise"): {"left": True, "top": False, "right": False, "bottom": True}, + ("bottom_left", "counterclockwise"): {"bottom": False, "right": True, "top": True, "left": False}, + ("bottom_right", "clockwise"): {"bottom": True, "left": True, "top": False, "right": False}, + ("bottom_right", "counterclockwise"): {"right": True, "top": True, "left": False, "bottom": False}, + ("top_left", "clockwise"): {"top": False, "right": False, "bottom": True, "left": True}, + ("top_left", "counterclockwise"): {"left": False, "bottom": False, "right": True, "top": True}, + ("top_right", "clockwise"): {"right": False, "bottom": True, "left": True, "top": False}, + ("top_right", "counterclockwise"): {"top": True, "left": False, "bottom": False, "right": True}, +} + @dataclass class CalibrationSegment: @@ -27,12 +52,52 @@ class CalibrationSegment: @dataclass class CalibrationConfig: - """Complete calibration configuration.""" + """Complete calibration configuration. + + Stores only the core parameters. Segments (with led_start, reverse, edge order) + are derived at runtime via the `segments` property. + """ layout: Literal["clockwise", "counterclockwise"] start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] - segments: List[CalibrationSegment] - offset: int = 0 # Physical LED offset from start corner (number of LEDs from LED 0 to start corner) + offset: int = 0 + leds_top: int = 0 + leds_right: int = 0 + leds_bottom: int = 0 + leds_left: int = 0 + + def build_segments(self) -> List[CalibrationSegment]: + """Derive segment list from core parameters.""" + key = (self.start_position, self.layout) + edge_order = EDGE_ORDER.get(key, ["bottom", "right", "top", "left"]) + reverse_map = EDGE_REVERSE.get(key, {}) + + led_counts = { + "top": self.leds_top, + "right": self.leds_right, + "bottom": self.leds_bottom, + "left": self.leds_left, + } + + segments = [] + led_start = 0 + for edge in edge_order: + count = led_counts[edge] + if count > 0: + segments.append(CalibrationSegment( + edge=edge, + led_start=led_start, + led_count=count, + reverse=reverse_map.get(edge, False), + )) + led_start += count + + return segments + + @property + def segments(self) -> List[CalibrationSegment]: + """Get derived segment list.""" + return self.build_segments() def validate(self) -> bool: """Validate calibration configuration. @@ -43,42 +108,20 @@ class CalibrationConfig: Raises: ValueError: If configuration is invalid """ - if not self.segments: - raise ValueError("Calibration must have at least one segment") + total = self.get_total_leds() + if total <= 0: + raise ValueError("Calibration must have at least one LED") - # Check for duplicate edges - edges = [seg.edge for seg in self.segments] - if len(edges) != len(set(edges)): - raise ValueError("Duplicate edges in calibration segments") - - # Validate LED indices don't overlap - led_ranges = [] - for seg in self.segments: - led_range = range(seg.led_start, seg.led_start + seg.led_count) - led_ranges.append(led_range) - - # Check for overlaps - for i, range1 in enumerate(led_ranges): - for j, range2 in enumerate(led_ranges): - if i != j: - overlap = set(range1) & set(range2) - if overlap: - raise ValueError( - f"LED indices overlap between segments {i} and {j}: {overlap}" - ) - - # Validate LED counts are positive - for seg in self.segments: - if seg.led_count <= 0: - raise ValueError(f"LED count must be positive, got {seg.led_count}") - if seg.led_start < 0: - raise ValueError(f"LED start must be non-negative, got {seg.led_start}") + for edge, count in [("top", self.leds_top), ("right", self.leds_right), + ("bottom", self.leds_bottom), ("left", self.leds_left)]: + if count < 0: + raise ValueError(f"LED count for {edge} must be non-negative, got {count}") return True def get_total_leds(self) -> int: - """Get total number of LEDs across all segments.""" - return sum(seg.led_count for seg in self.segments) + """Get total number of LEDs across all edges.""" + return self.leds_top + self.leds_right + self.leds_bottom + self.leds_left def get_segment_for_edge(self, edge: str) -> CalibrationSegment | None: """Get segment configuration for a specific edge.""" @@ -248,37 +291,13 @@ def create_default_calibration(led_count: int) -> CalibrationConfig: top_count = leds_per_edge + (1 if remainder > 1 else 0) left_count = leds_per_edge + (1 if remainder > 2 else 0) - segments = [ - CalibrationSegment( - edge="bottom", - led_start=0, - led_count=bottom_count, - reverse=False, - ), - CalibrationSegment( - edge="right", - led_start=bottom_count, - led_count=right_count, - reverse=False, - ), - CalibrationSegment( - edge="top", - led_start=bottom_count + right_count, - led_count=top_count, - reverse=True, - ), - CalibrationSegment( - edge="left", - led_start=bottom_count + right_count + top_count, - led_count=left_count, - reverse=True, - ), - ] - config = CalibrationConfig( layout="clockwise", start_position="bottom_left", - segments=segments, + leds_bottom=bottom_count, + leds_right=right_count, + leds_top=top_count, + leds_left=left_count, ) logger.info( @@ -293,6 +312,9 @@ def create_default_calibration(led_count: int) -> CalibrationConfig: def calibration_from_dict(data: dict) -> CalibrationConfig: """Create calibration configuration from dictionary. + Supports both new format (leds_top/right/bottom/left) and legacy format + (segments list) for backward compatibility. + Args: data: Dictionary with calibration data @@ -303,21 +325,14 @@ def calibration_from_dict(data: dict) -> CalibrationConfig: ValueError: If data is invalid """ try: - segments = [ - CalibrationSegment( - edge=seg["edge"], - led_start=seg["led_start"], - led_count=seg["led_count"], - reverse=seg.get("reverse", False), - ) - for seg in data["segments"] - ] - config = CalibrationConfig( layout=data["layout"], start_position=data["start_position"], - segments=segments, offset=data.get("offset", 0), + leds_top=data.get("leds_top", 0), + leds_right=data.get("leds_right", 0), + leds_bottom=data.get("leds_bottom", 0), + leds_left=data.get("leds_left", 0), ) config.validate() @@ -326,6 +341,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig: except KeyError as e: raise ValueError(f"Missing required calibration field: {e}") except Exception as e: + if isinstance(e, ValueError): + raise raise ValueError(f"Invalid calibration data: {e}") @@ -342,13 +359,8 @@ def calibration_to_dict(config: CalibrationConfig) -> dict: "layout": config.layout, "start_position": config.start_position, "offset": config.offset, - "segments": [ - { - "edge": seg.edge, - "led_start": seg.led_start, - "led_count": seg.led_count, - "reverse": seg.reverse, - } - for seg in config.segments - ], + "leds_top": config.leds_top, + "leds_right": config.leds_right, + "leds_bottom": config.leds_bottom, + "leds_left": config.leds_left, } diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index bbd4ac2..2f73b35 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -562,6 +562,9 @@ function createDeviceCard(device) { + @@ -663,34 +666,55 @@ async function removeDevice(deviceId) { async function showSettings(deviceId) { try { - // Fetch current device data - const response = await fetch(`${API_BASE}/devices/${deviceId}`, { - headers: getHeaders() - }); + // Fetch device data and displays in parallel + const [deviceResponse, displaysResponse] = await Promise.all([ + fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }), + fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), + ]); - if (response.status === 401) { + if (deviceResponse.status === 401) { handle401Error(); return; } - if (!response.ok) { + if (!deviceResponse.ok) { showToast('Failed to load device settings', 'error'); return; } - const device = await response.json(); + const device = await deviceResponse.json(); - // Populate modal + // Populate display index select + const displaySelect = document.getElementById('settings-display-index'); + displaySelect.innerHTML = ''; + if (displaysResponse.ok) { + const displaysData = await displaysResponse.json(); + (displaysData.displays || []).forEach(d => { + const opt = document.createElement('option'); + opt.value = d.index; + opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`; + displaySelect.appendChild(opt); + }); + } + if (displaySelect.options.length === 0) { + const opt = document.createElement('option'); + opt.value = '0'; + opt.textContent = '0'; + displaySelect.appendChild(opt); + } + displaySelect.value = String(device.settings.display_index ?? 0); + + // Populate other fields document.getElementById('settings-device-id').value = device.id; document.getElementById('settings-device-name').value = device.name; document.getElementById('settings-device-url').value = device.url; - // Set health check interval document.getElementById('settings-health-interval').value = device.settings.state_check_interval || 30; // Snapshot initial values for dirty checking settingsInitialValues = { name: device.name, url: device.url, + display_index: String(device.settings.display_index ?? 0), state_check_interval: String(device.settings.state_check_interval || 30), }; @@ -714,6 +738,7 @@ function isSettingsDirty() { return ( document.getElementById('settings-device-name').value !== settingsInitialValues.name || document.getElementById('settings-device-url').value !== settingsInitialValues.url || + document.getElementById('settings-display-index').value !== settingsInitialValues.display_index || document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ); } @@ -739,6 +764,7 @@ async function saveDeviceSettings() { const deviceId = document.getElementById('settings-device-id').value; const name = document.getElementById('settings-device-name').value.trim(); const url = document.getElementById('settings-device-url').value.trim(); + const display_index = parseInt(document.getElementById('settings-display-index').value) || 0; const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30; const error = document.getElementById('settings-error'); @@ -773,7 +799,7 @@ async function saveDeviceSettings() { const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { method: 'PUT', headers: getHeaders(), - body: JSON.stringify({ state_check_interval }) + body: JSON.stringify({ display_index, state_check_interval }) }); if (settingsResponse.status === 401) { @@ -817,14 +843,36 @@ async function saveCardBrightness(deviceId, value) { } } -// Add device form handler +// Add device modal +function showAddDevice() { + const modal = document.getElementById('add-device-modal'); + const form = document.getElementById('add-device-form'); + const error = document.getElementById('add-device-error'); + form.reset(); + error.style.display = 'none'; + modal.style.display = 'flex'; + lockBody(); + setTimeout(() => document.getElementById('device-name').focus(), 100); +} + +function closeAddDeviceModal() { + const modal = document.getElementById('add-device-modal'); + modal.style.display = 'none'; + unlockBody(); +} + async function handleAddDevice(event) { event.preventDefault(); - const name = document.getElementById('device-name').value; - const url = document.getElementById('device-url').value; + const name = document.getElementById('device-name').value.trim(); + const url = document.getElementById('device-url').value.trim(); + const error = document.getElementById('add-device-error'); - console.log(`Adding device: ${name} (${url})`); + if (!name || !url) { + error.textContent = 'Please fill in all fields'; + error.style.display = 'block'; + return; + } try { const response = await fetch(`${API_BASE}/devices`, { @@ -842,15 +890,16 @@ async function handleAddDevice(event) { const result = await response.json(); console.log('Device added successfully:', result); showToast('Device added successfully', 'success'); - event.target.reset(); + closeAddDeviceModal(); loadDevices(); } else { - const error = await response.json(); - console.error('Failed to add device:', error); - showToast(`Failed to add device: ${error.detail}`, 'error'); + const errorData = await response.json(); + console.error('Failed to add device:', errorData); + error.textContent = `Failed to add device: ${errorData.detail}`; + error.style.display = 'block'; } - } catch (error) { - console.error('Failed to add device:', error); + } catch (err) { + console.error('Failed to add device:', err); showToast('Failed to add device', 'error'); } } @@ -945,25 +994,20 @@ async function showCalibration(deviceId) { document.getElementById('cal-offset').value = calibration.offset || 0; // Set LED counts per edge - const edgeCounts = { top: 0, right: 0, bottom: 0, left: 0 }; - calibration.segments.forEach(seg => { - edgeCounts[seg.edge] = seg.led_count; - }); - - document.getElementById('cal-top-leds').value = edgeCounts.top; - document.getElementById('cal-right-leds').value = edgeCounts.right; - document.getElementById('cal-bottom-leds').value = edgeCounts.bottom; - document.getElementById('cal-left-leds').value = edgeCounts.left; + document.getElementById('cal-top-leds').value = calibration.leds_top || 0; + document.getElementById('cal-right-leds').value = calibration.leds_right || 0; + document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0; + document.getElementById('cal-left-leds').value = calibration.leds_left || 0; // Snapshot initial values for dirty checking calibrationInitialValues = { start_position: calibration.start_position, layout: calibration.layout, offset: String(calibration.offset || 0), - top: String(edgeCounts.top), - right: String(edgeCounts.right), - bottom: String(edgeCounts.bottom), - left: String(edgeCounts.left), + top: String(calibration.leds_top || 0), + right: String(calibration.leds_right || 0), + bottom: String(calibration.leds_bottom || 0), + left: String(calibration.leds_left || 0), }; // Initialize test mode state for this device @@ -1167,40 +1211,16 @@ async function saveCalibration() { // Build calibration config const startPosition = document.getElementById('cal-start-position').value; const layout = document.getElementById('cal-layout').value; - - // Build segments based on start position and direction - const segments = []; - let ledStart = 0; - - const edgeOrder = getEdgeOrder(startPosition, layout); - - const edgeCounts = { - top: topLeds, - right: rightLeds, - bottom: bottomLeds, - left: leftLeds - }; - - edgeOrder.forEach(edge => { - const count = edgeCounts[edge]; - if (count > 0) { - segments.push({ - edge: edge, - led_start: ledStart, - led_count: count, - reverse: shouldReverse(edge, startPosition, layout) - }); - ledStart += count; - } - }); - const offset = parseInt(document.getElementById('cal-offset').value || 0); const calibration = { layout: layout, start_position: startPosition, offset: offset, - segments: segments + leds_top: topLeds, + leds_right: rightLeds, + leds_bottom: bottomLeds, + leds_left: leftLeds }; try { @@ -1232,43 +1252,465 @@ async function saveCalibration() { } function getEdgeOrder(startPosition, layout) { - const clockwise = ['bottom', 'right', 'top', 'left']; - const counterclockwise = ['bottom', 'left', 'top', 'right']; - const orders = { - 'bottom_left_clockwise': clockwise, - 'bottom_left_counterclockwise': counterclockwise, + 'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'], + 'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'], 'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'], - 'bottom_right_counterclockwise': ['bottom', 'right', 'top', 'left'], + 'bottom_right_counterclockwise': ['right', 'top', 'left', 'bottom'], 'top_left_clockwise': ['top', 'right', 'bottom', 'left'], - 'top_left_counterclockwise': ['top', 'left', 'bottom', 'right'], - 'top_right_clockwise': ['top', 'left', 'bottom', 'right'], - 'top_right_counterclockwise': ['top', 'right', 'bottom', 'left'] + 'top_left_counterclockwise': ['left', 'bottom', 'right', 'top'], + 'top_right_clockwise': ['right', 'bottom', 'left', 'top'], + 'top_right_counterclockwise': ['top', 'left', 'bottom', 'right'] }; - return orders[`${startPosition}_${layout}`] || clockwise; + return orders[`${startPosition}_${layout}`] || ['left', 'top', 'right', 'bottom']; } function shouldReverse(edge, startPosition, layout) { // Determine if this edge should be reversed based on LED strip direction const reverseRules = { - 'bottom_left_clockwise': { bottom: false, right: false, top: true, left: true }, + 'bottom_left_clockwise': { left: true, top: false, right: false, bottom: true }, 'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false }, - 'bottom_right_clockwise': { bottom: true, right: false, top: false, left: true }, - 'bottom_right_counterclockwise': { bottom: true, right: true, top: false, left: false }, + 'bottom_right_clockwise': { bottom: true, left: true, top: false, right: false }, + 'bottom_right_counterclockwise': { right: true, top: true, left: false, bottom: false }, 'top_left_clockwise': { top: false, right: false, bottom: true, left: true }, - 'top_left_counterclockwise': { top: false, right: true, bottom: true, left: false }, - 'top_right_clockwise': { top: true, right: false, bottom: false, left: true }, - 'top_right_counterclockwise': { top: true, right: true, bottom: false, left: false } + 'top_left_counterclockwise': { left: false, bottom: false, right: true, top: true }, + 'top_right_clockwise': { right: false, bottom: true, left: true, top: false }, + 'top_right_counterclockwise': { top: true, left: false, bottom: false, right: true } }; const rules = reverseRules[`${startPosition}_${layout}`]; return rules ? rules[edge] : false; } -// Close modals on backdrop click +function buildSegments(calibration) { + const edgeOrder = getEdgeOrder(calibration.start_position, calibration.layout); + const edgeCounts = { + top: calibration.leds_top || 0, + right: calibration.leds_right || 0, + bottom: calibration.leds_bottom || 0, + left: calibration.leds_left || 0 + }; + + const segments = []; + let ledStart = 0; + + edgeOrder.forEach(edge => { + const count = edgeCounts[edge]; + if (count > 0) { + segments.push({ + edge: edge, + led_start: ledStart, + led_count: count, + reverse: shouldReverse(edge, calibration.start_position, calibration.layout) + }); + ledStart += count; + } + }); + + return segments; +} + +// Pixel Layout Preview functions + +async function showPixelPreview(deviceId) { + try { + const [deviceResponse, displaysResponse] = await Promise.all([ + fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }), + fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), + ]); + + if (deviceResponse.status === 401) { + handle401Error(); + return; + } + + if (!deviceResponse.ok) { + showToast('Failed to load device data', 'error'); + return; + } + + const device = await deviceResponse.json(); + const calibration = device.calibration; + + const totalLeds = (calibration?.leds_top || 0) + (calibration?.leds_right || 0) + + (calibration?.leds_bottom || 0) + (calibration?.leds_left || 0); + if (!calibration || totalLeds === 0) { + showToast(t('preview.no_calibration'), 'error'); + return; + } + + // Derive segments from core parameters + calibration.segments = buildSegments(calibration); + + let displayWidth = 1920; + let displayHeight = 1080; + if (displaysResponse.ok) { + const displaysData = await displaysResponse.json(); + const displayIndex = device.settings.display_index || 0; + const display = (displaysData.displays || []).find(d => d.index === displayIndex); + if (display) { + displayWidth = display.width; + displayHeight = display.height; + } + } + + const overlay = document.getElementById('pixel-preview-overlay'); + overlay.style.display = 'flex'; + lockBody(); + + document.getElementById('pixel-preview-device-name').textContent = + `${device.name} (${device.led_count} LEDs)`; + + buildPreviewLegend(calibration); + + // Render after layout settles + requestAnimationFrame(() => { + renderPixelPreview(calibration, displayWidth, displayHeight); + }); + + overlay._resizeHandler = () => { + renderPixelPreview(calibration, displayWidth, displayHeight); + }; + window.addEventListener('resize', overlay._resizeHandler); + + } catch (error) { + console.error('Failed to show pixel preview:', error); + showToast('Failed to load pixel preview', 'error'); + } +} + +function closePixelPreview() { + const overlay = document.getElementById('pixel-preview-overlay'); + overlay.style.display = 'none'; + unlockBody(); + + if (overlay._resizeHandler) { + window.removeEventListener('resize', overlay._resizeHandler); + overlay._resizeHandler = null; + } +} + +function buildPreviewLegend(calibration) { + const legendContainer = document.getElementById('pixel-preview-legend'); + const edgeNames = { + top: t('preview.edge.top'), + right: t('preview.edge.right'), + bottom: t('preview.edge.bottom'), + left: t('preview.edge.left') + }; + + const items = calibration.segments.map(seg => { + const [r, g, b] = EDGE_TEST_COLORS[seg.edge] || [128, 128, 128]; + const last = seg.led_start + seg.led_count - 1; + return `
+
+ ${edgeNames[seg.edge] || seg.edge}: ${seg.led_count} LEDs (#${seg.led_start}\u2013${last}) +
`; + }); + + const dirText = calibration.layout === 'clockwise' ? t('preview.direction.cw') : t('preview.direction.ccw'); + items.push(`
+ ${calibration.layout === 'clockwise' ? '\u21BB' : '\u21BA'} ${dirText} +
`); + + if (calibration.offset > 0) { + items.push(`
+ \u2194 ${t('preview.offset_leds', { count: calibration.offset })} +
`); + } + + legendContainer.innerHTML = items.join(''); +} + +function renderPixelPreview(calibration, displayWidth, displayHeight) { + const canvas = document.getElementById('pixel-preview-canvas'); + const ctx = canvas.getContext('2d'); + + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + + const W = rect.width; + const H = rect.height; + + // Clear + ctx.fillStyle = '#111111'; + ctx.fillRect(0, 0, W, H); + + // Calculate screen rectangle with proper aspect ratio + const padding = 80; + const maxScreenW = W - padding * 2; + const maxScreenH = H - padding * 2; + if (maxScreenW <= 0 || maxScreenH <= 0) return; + + const displayAspect = displayWidth / displayHeight; + let screenW, screenH; + + if (maxScreenW / maxScreenH > displayAspect) { + screenH = maxScreenH; + screenW = screenH * displayAspect; + } else { + screenW = maxScreenW; + screenH = screenW / displayAspect; + } + + const screenX = (W - screenW) / 2; + const screenY = (H - screenH) / 2; + + // Draw screen rectangle + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(screenX, screenY, screenW, screenH); + ctx.strokeStyle = '#444'; + ctx.lineWidth = 2; + ctx.strokeRect(screenX, screenY, screenW, screenH); + + // Screen label + ctx.fillStyle = '#555'; + ctx.font = `${Math.min(24, screenH * 0.06)}px -apple-system, BlinkMacSystemFont, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(`${displayWidth}\u00D7${displayHeight}`, screenX + screenW / 2, screenY + screenH / 2); + + // LED rendering config + const maxEdgeLeds = Math.max(...calibration.segments.map(s => s.led_count), 1); + const ledMarkerSize = Math.max(2, Math.min(8, 500 / maxEdgeLeds)); + const stripOffset = ledMarkerSize + 10; + + // Edge geometry + const edgeGeometry = { + top: { x1: screenX, y1: screenY, x2: screenX + screenW, y2: screenY, horizontal: true, outside: -1 }, + bottom: { x1: screenX, y1: screenY + screenH, x2: screenX + screenW, y2: screenY + screenH, horizontal: true, outside: 1 }, + left: { x1: screenX, y1: screenY, x2: screenX, y2: screenY + screenH, horizontal: false, outside: -1 }, + right: { x1: screenX + screenW, y1: screenY, x2: screenX + screenW, y2: screenY + screenH, horizontal: false, outside: 1 }, + }; + + // Draw each segment's LEDs + calibration.segments.forEach(seg => { + const [r, g, b] = EDGE_TEST_COLORS[seg.edge] || [128, 128, 128]; + const geo = edgeGeometry[seg.edge]; + if (!geo) return; + + const positions = []; + + for (let i = 0; i < seg.led_count; i++) { + const fraction = seg.led_count > 1 ? i / (seg.led_count - 1) : 0.5; + const displayFraction = seg.reverse ? (1 - fraction) : fraction; + + let cx, cy; + if (geo.horizontal) { + cx = geo.x1 + displayFraction * (geo.x2 - geo.x1); + cy = geo.y1 + geo.outside * stripOffset; + } else { + cx = geo.x1 + geo.outside * stripOffset; + cy = geo.y1 + displayFraction * (geo.y2 - geo.y1); + } + + positions.push({ cx, cy, ledIndex: seg.led_start + i }); + + // Draw LED marker + ctx.fillStyle = `rgba(${r},${g},${b},0.85)`; + ctx.beginPath(); + ctx.arc(cx, cy, ledMarkerSize / 2, 0, Math.PI * 2); + ctx.fill(); + } + + // Draw LED index labels + drawPreviewLedLabels(ctx, positions, ledMarkerSize, geo); + }); + + // Draw start position marker + drawPreviewStartPosition(ctx, calibration, screenX, screenY, screenW, screenH); + + // Draw direction arrows + drawPreviewDirectionArrows(ctx, calibration, edgeGeometry); + + // Draw offset indicator + if (calibration.offset > 0) { + drawPreviewOffsetIndicator(ctx, calibration, screenX, screenY, screenW, screenH); + } +} + +function drawPreviewLedLabels(ctx, positions, markerSize, geo) { + if (positions.length === 0) return; + + const labelFontSize = Math.max(9, Math.min(12, 200 / Math.sqrt(positions.length))); + ctx.font = `${labelFontSize}px -apple-system, BlinkMacSystemFont, sans-serif`; + ctx.fillStyle = '#ccc'; + ctx.textBaseline = 'middle'; + + // Adaptive label interval + const count = positions.length; + const labelInterval = count <= 20 ? 1 + : count <= 50 ? 5 + : count <= 100 ? 10 + : count <= 200 ? 25 + : 50; + + const labelsToShow = new Set(); + labelsToShow.add(0); + labelsToShow.add(count - 1); + for (let i = labelInterval; i < count - 1; i += labelInterval) { + labelsToShow.add(i); + } + + const labelOffset = markerSize / 2 + labelFontSize; + + labelsToShow.forEach(i => { + const pos = positions[i]; + const label = String(pos.ledIndex); + + if (geo.horizontal) { + ctx.textAlign = 'center'; + ctx.fillText(label, pos.cx, pos.cy + geo.outside * labelOffset); + } else { + ctx.textAlign = geo.outside < 0 ? 'right' : 'left'; + ctx.fillText(label, pos.cx + geo.outside * labelOffset, pos.cy); + } + }); +} + +function drawPreviewStartPosition(ctx, calibration, screenX, screenY, screenW, screenH) { + const corners = { + top_left: { x: screenX, y: screenY }, + top_right: { x: screenX + screenW, y: screenY }, + bottom_left: { x: screenX, y: screenY + screenH }, + bottom_right: { x: screenX + screenW, y: screenY + screenH }, + }; + + const corner = corners[calibration.start_position]; + if (!corner) return; + + // Green diamond + const size = 10; + ctx.save(); + ctx.translate(corner.x, corner.y); + ctx.rotate(Math.PI / 4); + ctx.fillStyle = '#4CAF50'; + ctx.shadowColor = 'rgba(76, 175, 80, 0.6)'; + ctx.shadowBlur = 8; + ctx.fillRect(-size / 2, -size / 2, size, size); + ctx.restore(); + + // START label + ctx.save(); + ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, sans-serif'; + ctx.fillStyle = '#4CAF50'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + const lx = calibration.start_position.includes('left') ? -28 : 28; + const ly = calibration.start_position.includes('top') ? -18 : 18; + ctx.fillText(t('preview.start'), corner.x + lx, corner.y + ly); + ctx.restore(); +} + +function drawPreviewDirectionArrows(ctx, calibration, edgeGeometry) { + const arrowSize = 8; + ctx.save(); + ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; + + calibration.segments.forEach(seg => { + const geo = edgeGeometry[seg.edge]; + if (!geo) return; + + // Midpoint of edge, shifted outside + const midFraction = 0.5; + let mx, my; + if (geo.horizontal) { + mx = geo.x1 + midFraction * (geo.x2 - geo.x1); + my = geo.y1 + geo.outside * (arrowSize + 20); + } else { + mx = geo.x1 + geo.outside * (arrowSize + 20); + my = geo.y1 + midFraction * (geo.y2 - geo.y1); + } + + // Direction based on edge and reverse + let angle; + if (geo.horizontal) { + angle = seg.reverse ? Math.PI : 0; // left or right + } else { + angle = seg.reverse ? -Math.PI / 2 : Math.PI / 2; // up or down + } + + ctx.save(); + ctx.translate(mx, my); + ctx.rotate(angle); + ctx.beginPath(); + ctx.moveTo(arrowSize, 0); + ctx.lineTo(-arrowSize / 2, -arrowSize / 2); + ctx.lineTo(-arrowSize / 2, arrowSize / 2); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + }); + + ctx.restore(); +} + +function drawPreviewOffsetIndicator(ctx, calibration, screenX, screenY, screenW, screenH) { + const corners = { + top_left: { x: screenX, y: screenY }, + top_right: { x: screenX + screenW, y: screenY }, + bottom_left: { x: screenX, y: screenY + screenH }, + bottom_right: { x: screenX + screenW, y: screenY + screenH }, + }; + + const corner = corners[calibration.start_position]; + if (!corner) return; + + ctx.save(); + ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif'; + ctx.fillStyle = '#ff9800'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + const ox = calibration.start_position.includes('left') ? -45 : 45; + const oy = calibration.start_position.includes('top') ? -35 : 35; + ctx.fillText( + t('preview.offset_leds', { count: calibration.offset }), + corner.x + ox, + corner.y + oy + ); + + // Dashed arc + ctx.strokeStyle = '#ff9800'; + ctx.lineWidth = 1.5; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.arc(corner.x, corner.y, 18, 0, Math.PI * 0.5); + ctx.stroke(); + ctx.setLineDash([]); + + ctx.restore(); +} + +// Close pixel preview on canvas click +document.getElementById('pixel-preview-canvas').addEventListener('click', () => { + closePixelPreview(); +}); + +// Close pixel preview on Escape key +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + const overlay = document.getElementById('pixel-preview-overlay'); + if (overlay.style.display !== 'none') { + closePixelPreview(); + return; + } + } +}); + +// Close modals on backdrop click (only if mousedown also started on backdrop) +let backdropMouseDownTarget = null; +document.addEventListener('mousedown', (e) => { + backdropMouseDownTarget = e.target; +}); document.addEventListener('click', (e) => { if (!e.target.classList.contains('modal')) return; + if (backdropMouseDownTarget !== e.target) return; const modalId = e.target.id; @@ -1298,6 +1740,12 @@ document.addEventListener('click', (e) => { closeCalibrationModal(); return; } + + // Add device modal: close on backdrop + if (modalId === 'add-device-modal') { + closeAddDeviceModal(); + return; + } }); // Cleanup on page unload diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 374f770..a42b578 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -45,32 +45,15 @@
-

WLED Devices

+
+

WLED Devices

+ +
Loading devices...
-
-

Add New Device

-
- 📱 WLED Configuration: Configure your WLED device (effects, segments, color order, power limits, etc.) using the - official WLED app. - This controller sends pixel color data and controls brightness per device. -
-
-
- - -
-
- - -
- -
-
-