diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 67a817b..ac863e3 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -35,6 +35,25 @@ netstat -an | grep 8080 - Documentation files (`*.md`) - Configuration files in `config/` if server supports hot-reload (check implementation) +### Git Push Policy + +**CRITICAL**: NEVER push commits to the remote repository without explicit user approval. + +#### Rules + +- You MAY create commits when requested or when appropriate +- You MUST NOT push commits unless explicitly instructed by the user +- If the user says "commit", create a commit but DO NOT push +- If the user says "commit and push", you may push after committing +- Always wait for explicit permission before any push operation + +#### Workflow + +1. Make changes to code +2. Create commit when appropriate (with user consent) +3. **STOP and WAIT** - do not push +4. Only push when user explicitly requests it (e.g., "push", "commit and push", "push to remote") + ### Testing Changes After restarting the server with new code: diff --git a/server/config/default_config.yaml b/server/config/default_config.yaml index 25ae512..6643f98 100644 --- a/server/config/default_config.yaml +++ b/server/config/default_config.yaml @@ -10,10 +10,7 @@ auth: # Format: label: "api-key" api_keys: # Generate secure keys: openssl rand -hex 32 - # IMPORTANT: Add at least one key before starting the server - # home_assistant: "your-secure-api-key-1" - # web_dashboard: "your-secure-api-key-2" - # monitoring_script: "your-secure-api-key-3" + dev: "development-key-change-in-production" # Development key - CHANGE THIS! processing: default_fps: 30 diff --git a/server/scripts/restart-server.bat b/server/scripts/restart-server.bat new file mode 100644 index 0000000..9983df4 --- /dev/null +++ b/server/scripts/restart-server.bat @@ -0,0 +1,24 @@ +@echo off +REM WLED Screen Controller Restart Script +REM This script restarts the WLED screen controller server + +echo Restarting WLED Screen Controller... +echo. + +REM Stop the server first +echo [1/2] Stopping server... +call "%~dp0\stop-server.bat" + +REM Wait a moment +timeout /t 2 /nobreak >nul + +REM Change to parent directory (server root) +cd /d "%~dp0\.." + +REM Start the server +echo. +echo [2/2] Starting server... +python -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080 + +REM If the server exits, pause to show any error messages +pause diff --git a/server/scripts/start-server-background.vbs b/server/scripts/start-server-background.vbs new file mode 100644 index 0000000..9476167 --- /dev/null +++ b/server/scripts/start-server-background.vbs @@ -0,0 +1,7 @@ +Set WshShell = CreateObject("WScript.Shell") +Set FSO = CreateObject("Scripting.FileSystemObject") +' Get parent folder of scripts folder (server root) +WshShell.CurrentDirectory = FSO.GetParentFolderName(FSO.GetParentFolderName(WScript.ScriptFullName)) +WshShell.Run "python -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080", 0, False +Set FSO = Nothing +Set WshShell = Nothing diff --git a/server/scripts/start-server.bat b/server/scripts/start-server.bat new file mode 100644 index 0000000..14e8f25 --- /dev/null +++ b/server/scripts/start-server.bat @@ -0,0 +1,15 @@ +@echo off +REM WLED Screen Controller Startup Script +REM This script starts the WLED screen controller server + +echo Starting WLED Screen Controller... +echo. + +REM Change to the server directory (parent of scripts folder) +cd /d "%~dp0\.." + +REM Start the server +python -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080 + +REM If the server exits, pause to show any error messages +pause diff --git a/server/scripts/stop-server.bat b/server/scripts/stop-server.bat new file mode 100644 index 0000000..778910b --- /dev/null +++ b/server/scripts/stop-server.bat @@ -0,0 +1,19 @@ +@echo off +REM WLED Screen Controller Stop Script +REM This script stops the running WLED screen controller server + +echo Stopping WLED Screen Controller... +echo. + +REM Find and kill Python processes running wled_controller.main +for /f "tokens=2" %%i in ('tasklist /FI "IMAGENAME eq python.exe" /FO LIST ^| findstr /B "PID:"') do ( + wmic process where "ProcessId=%%i" get CommandLine 2>nul | findstr /C:"wled_controller.main" >nul + if not errorlevel 1 ( + taskkill /PID %%i /F + echo WLED controller process (PID %%i) terminated. + ) +) + +echo. +echo Done! WLED Screen Controller stopped. +pause diff --git a/server/src/wled_controller/api/schemas.py b/server/src/wled_controller/api/schemas.py index bdf180f..7643d6f 100644 --- a/server/src/wled_controller/api/schemas.py +++ b/server/src/wled_controller/api/schemas.py @@ -106,6 +106,11 @@ class Calibration(BaseModel): default="bottom_left", description="Position of LED index 0" ) + 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, diff --git a/server/src/wled_controller/core/calibration.py b/server/src/wled_controller/core/calibration.py index d61498f..df86568 100644 --- a/server/src/wled_controller/core/calibration.py +++ b/server/src/wled_controller/core/calibration.py @@ -32,6 +32,7 @@ class CalibrationConfig: 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) def validate(self) -> bool: """Validate calibration configuration. @@ -181,7 +182,14 @@ class PixelMapper: color = self._calc_color(pixel_segment) led_colors[led_idx] = color - logger.debug(f"Mapped border pixels to {total_leds} LED colors") + # Apply physical LED offset by rotating the array + # Offset = number of LEDs from LED 0 to the start corner + # Physical LED[i] should get calibration color[(i - offset) % total] + offset = self.calibration.offset % total_leds if total_leds > 0 else 0 + if offset > 0: + led_colors = led_colors[total_leds - offset:] + led_colors[:total_leds - offset] + + logger.debug(f"Mapped border pixels to {total_leds} LED colors (offset={offset})") return led_colors def test_calibration(self, edge: str, color: Tuple[int, int, int]) -> List[Tuple[int, int, int]]: @@ -309,6 +317,7 @@ def calibration_from_dict(data: dict) -> CalibrationConfig: layout=data["layout"], start_position=data["start_position"], segments=segments, + offset=data.get("offset", 0), ) config.validate() @@ -332,6 +341,7 @@ def calibration_to_dict(config: CalibrationConfig) -> dict: return { "layout": config.layout, "start_position": config.start_position, + "offset": config.offset, "segments": [ { "edge": seg.edge, diff --git a/server/src/wled_controller/core/ddp_client.py b/server/src/wled_controller/core/ddp_client.py new file mode 100644 index 0000000..2038908 --- /dev/null +++ b/server/src/wled_controller/core/ddp_client.py @@ -0,0 +1,185 @@ +"""DDP (Distributed Display Protocol) client for WLED.""" +import asyncio +import struct +from typing import List, Tuple + +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class DDPClient: + """UDP DDP client for sending pixel data to WLED devices.""" + + DDP_PORT = 4048 + DDP_FLAGS_VER1 = 0x40 # VER=1, TIMECODE=0, STORAGE=0, REPLY=0, QUERY=0, PUSH=1 + DDP_TYPE_RGB = 0x01 + + # Color order mappings: input (R,G,B) -> output indices + COLOR_ORDER_MAP = { + 0: (1, 0, 2), # GRB: G=idx1, R=idx0, B=idx2 + 1: (0, 1, 2), # RGB: R=idx0, G=idx1, B=idx2 + 2: (2, 0, 1), # BRG: B=idx2, R=idx0, G=idx1 + 3: (0, 2, 1), # RBG: R=idx0, B=idx2, G=idx1 + 4: (2, 1, 0), # BGR: B=idx2, G=idx1, R=idx0 + 5: (1, 2, 0), # GBR: G=idx1, B=idx2, R=idx0 + } + + def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False, color_order: int = 1): + """Initialize DDP client. + + Args: + host: WLED device IP address or hostname + port: DDP port (default 4048) + rgbw: True for RGBW LEDs (4 bytes/LED), False for RGB (3 bytes/LED) + color_order: Color order (0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR) + """ + self.host = host + self.port = port + self.rgbw = rgbw + self.color_order = color_order + self._transport = None + self._protocol = None + self._sequence = 0 + + async def connect(self): + """Establish UDP connection.""" + try: + loop = asyncio.get_event_loop() + self._transport, self._protocol = await loop.create_datagram_endpoint( + asyncio.DatagramProtocol, + remote_addr=(self.host, self.port) + ) + logger.info(f"DDP client connected to {self.host}:{self.port}") + return True + except Exception as e: + logger.error(f"Failed to connect DDP client: {e}") + raise + + async def close(self): + """Close the connection.""" + if self._transport: + self._transport.close() + self._transport = None + self._protocol = None + logger.debug(f"Closed DDP connection to {self.host}:{self.port}") + + def _build_ddp_packet( + self, + rgb_data: bytes, + offset: int = 0, + sequence: int = 1 + ) -> bytes: + """Build a DDP packet. + + DDP packet format (10-byte header + data): + - Byte 0: Flags (0x40 = VER1, PUSH) + - Byte 1: Sequence number + - Byte 2: Data type (0x01 = RGB) + - Byte 3: Source/Destination ID + - Bytes 4-7: Data offset in bytes (4-byte little-endian) + - Bytes 8-9: Data length in bytes (2-byte big-endian) + - Bytes 10+: Pixel data + + Args: + rgb_data: RGB pixel data as bytes + offset: Byte offset (pixel_index * 3) + sequence: Sequence number (0-255) + + Returns: + Complete DDP packet as bytes + """ + flags = self.DDP_FLAGS_VER1 + data_type = self.DDP_TYPE_RGB + source_id = 0x01 + data_len = len(rgb_data) + + # Build header (10 bytes) + header = struct.pack( + '!BBB B I H', # Network byte order (big-endian) + flags, # Flags + sequence, # Sequence + data_type, # Data type + source_id, # Source/Destination + offset, # Data offset (4 bytes) + data_len # Data length (2 bytes) + ) + + return header + rgb_data + + async def send_pixels( + self, + pixels: List[Tuple[int, int, int]], + max_packet_size: int = 1400 + ) -> bool: + """Send pixel data via DDP. + + Args: + pixels: List of (R, G, B) tuples + max_packet_size: Maximum UDP packet size (default 1400 bytes for safety) + + Returns: + True if successful + + Raises: + RuntimeError: If send fails + """ + if not self._transport: + raise RuntimeError("DDP client not connected") + + try: + # Get color order mapping + order_map = self.COLOR_ORDER_MAP.get(self.color_order, (0, 1, 2)) + + # Convert pixels to flat RGB or RGBW bytes with color reordering + pixel_bytes = bytearray() + for r, g, b in pixels: + # Reorder color channels based on WLED configuration + rgb = [int(r), int(g), int(b)] + reordered = [rgb[order_map[0]], rgb[order_map[1]], rgb[order_map[2]]] + pixel_bytes.extend(reordered) + if self.rgbw: + pixel_bytes.append(0) # White channel = 0 + + total_bytes = len(pixel_bytes) + bytes_per_packet = max_packet_size - 10 # Account for 10-byte header + + # Split into multiple packets if needed + num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet + + logger.debug( + f"Sending {len(pixels)} pixels ({total_bytes} bytes) " + f"in {num_packets} DDP packet(s)" + ) + + for i in range(num_packets): + start = i * bytes_per_packet + end = min(start + bytes_per_packet, total_bytes) + chunk = bytes(pixel_bytes[start:end]) + + # Increment sequence number + self._sequence = (self._sequence + 1) % 256 + + # Build and send packet + packet = self._build_ddp_packet(chunk, offset=start, sequence=self._sequence) + self._transport.sendto(packet) + + logger.debug( + f"Sent DDP packet {i+1}/{num_packets}: " + f"{len(chunk)} bytes at offset {start}" + ) + + return True + + except Exception as e: + logger.error(f"Failed to send DDP pixels: {e}") + raise RuntimeError(f"DDP send failed: {e}") + + async def __aenter__(self): + """Async context manager entry.""" + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self.close() diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index 33ad1cd..a979472 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -206,8 +206,13 @@ class ProcessorManager: # Connect to WLED device try: - state.wled_client = WLEDClient(state.device_url) + # Enable DDP for large LED counts (>500 LEDs) + use_ddp = state.led_count > 500 + state.wled_client = WLEDClient(state.device_url, use_ddp=use_ddp) await state.wled_client.connect() + + if use_ddp: + logger.info(f"Device {device_id} using DDP protocol ({state.led_count} LEDs)") except Exception as e: logger.error(f"Failed to connect to WLED device {device_id}: {e}") raise RuntimeError(f"Failed to connect to WLED device: {e}") @@ -287,26 +292,29 @@ class ProcessorManager: loop_start = time.time() try: - # Capture screen - capture = capture_display(settings.display_index) + # Run blocking operations in thread pool to avoid blocking event loop + # Capture screen (blocking I/O) + capture = await asyncio.to_thread(capture_display, settings.display_index) - # Extract border pixels - border_pixels = extract_border_pixels(capture, settings.border_width) + # Extract border pixels (CPU-intensive) + border_pixels = await asyncio.to_thread(extract_border_pixels, capture, settings.border_width) - # Map to LED colors - led_colors = state.pixel_mapper.map_border_to_leds(border_pixels) + # Map to LED colors (CPU-intensive) + led_colors = await asyncio.to_thread(state.pixel_mapper.map_border_to_leds, border_pixels) - # Apply color correction - led_colors = apply_color_correction( + # Apply color correction (CPU-intensive) + led_colors = await asyncio.to_thread( + apply_color_correction, led_colors, gamma=settings.gamma, saturation=settings.saturation, brightness=settings.brightness, ) - # Apply smoothing + # Apply smoothing (CPU-intensive) if state.previous_colors and settings.smoothing > 0: - led_colors = smooth_colors( + led_colors = await asyncio.to_thread( + smooth_colors, led_colors, state.previous_colors, settings.smoothing, @@ -331,7 +339,7 @@ class ProcessorManager: except Exception as e: state.metrics.errors_count += 1 state.metrics.last_error = str(e) - logger.error(f"Processing error for device {device_id}: {e}") + logger.error(f"Processing error for device {device_id}: {e}", exc_info=True) # FPS control elapsed = time.time() - loop_start diff --git a/server/src/wled_controller/core/wled_client.py b/server/src/wled_controller/core/wled_client.py index 0f7ccb0..ff18714 100644 --- a/server/src/wled_controller/core/wled_client.py +++ b/server/src/wled_controller/core/wled_client.py @@ -1,12 +1,14 @@ -"""WLED HTTP client for controlling LED devices.""" +"""WLED client for controlling LED devices via HTTP or DDP.""" import asyncio from dataclasses import dataclass from typing import List, Tuple, Optional, Dict, Any +from urllib.parse import urlparse import httpx from wled_controller.utils import get_logger +from wled_controller.core.ddp_client import DDPClient logger = get_logger(__name__) @@ -22,10 +24,15 @@ class WLEDInfo: product: str mac: str ip: str + rgbw: bool = False # True if RGBW LEDs (4 bytes/pixel), False if RGB (3 bytes/pixel) + color_order: int = 1 # Color order: 0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR class WLEDClient: - """HTTP client for WLED devices.""" + """Client for WLED devices supporting both HTTP and DDP protocols.""" + + # HTTP JSON API has ~10KB limit, ~500 LEDs max + HTTP_MAX_LEDS = 500 def __init__( self, @@ -33,6 +40,7 @@ class WLEDClient: timeout: int = 5, retry_attempts: int = 3, retry_delay: int = 1, + use_ddp: bool = False, ): """Initialize WLED client. @@ -41,13 +49,20 @@ class WLEDClient: timeout: Request timeout in seconds retry_attempts: Number of retry attempts on failure retry_delay: Delay between retries in seconds + use_ddp: Force DDP protocol (auto-enabled for >500 LEDs) """ self.url = url.rstrip("/") self.timeout = timeout self.retry_attempts = retry_attempts self.retry_delay = retry_delay + self.use_ddp = use_ddp + + # Extract hostname/IP from URL for DDP + parsed = urlparse(self.url) + self.host = parsed.hostname or parsed.netloc.split(':')[0] self._client: Optional[httpx.AsyncClient] = None + self._ddp_client: Optional[DDPClient] = None self._connected = False async def __aenter__(self): @@ -69,15 +84,45 @@ class WLEDClient: RuntimeError: If connection fails """ try: + # Always create HTTP client for info/control self._client = httpx.AsyncClient(timeout=self.timeout) # Test connection by getting device info info = await self.get_info() + + # Auto-enable DDP for large LED counts + if info.led_count > self.HTTP_MAX_LEDS and not self.use_ddp: + logger.info( + f"Device has {info.led_count} LEDs (>{self.HTTP_MAX_LEDS}), " + "auto-enabling DDP protocol" + ) + self.use_ddp = True + + # Create DDP client if needed + if self.use_ddp: + # DDP always uses RGB mode (3 bytes/LED) - WLED handles RGBW and color order internally + self._ddp_client = DDPClient(self.host, rgbw=False, color_order=1) + await self._ddp_client.connect() + + # Turn on the device and disable Audio Reactive mode for DDP + try: + await self._request("POST", "/json/state", json_data={ + "on": True, + "live": True, + "AudioReactive": {"on": False} + }) + logger.debug("Turned on device and enabled live mode for DDP streaming") + except Exception as e: + logger.warning(f"Could not configure device for DDP: {e}") + + logger.info(f"DDP protocol enabled for pixel data transmission (RGB mode)") + self._connected = True + protocol = "DDP" if self.use_ddp else "HTTP" logger.info( f"Connected to WLED device: {info.name} ({info.version}) " - f"with {info.led_count} LEDs" + f"with {info.led_count} LEDs via {protocol}" ) return True @@ -87,6 +132,9 @@ class WLEDClient: if self._client: await self._client.aclose() self._client = None + if self._ddp_client: + await self._ddp_client.close() + self._ddp_client = None raise RuntimeError(f"Failed to connect to WLED device: {e}") async def close(self): @@ -94,6 +142,9 @@ class WLEDClient: if self._client: await self._client.aclose() self._client = None + if self._ddp_client: + await self._ddp_client.close() + self._ddp_client = None self._connected = False logger.debug(f"Closed connection to {self.url}") @@ -175,16 +226,29 @@ class WLEDClient: RuntimeError: If request fails """ try: + # Get basic info data = await self._request("GET", "/json/info") + leds_info = data.get("leds", {}) + + # Get LED configuration for color order + cfg_data = await self._request("GET", "/json/cfg") + color_order = 1 # Default to RGB + if "hw" in cfg_data and "led" in cfg_data["hw"]: + led_cfg = cfg_data["hw"]["led"] + if "ins" in led_cfg and len(led_cfg["ins"]) > 0: + # Use color order from first LED strip + color_order = led_cfg["ins"][0].get("order", 1) return WLEDInfo( name=data.get("name", "Unknown"), version=data.get("ver", "Unknown"), - led_count=data.get("leds", {}).get("count", 0), + led_count=leds_info.get("count", 0), brand=data.get("brand", "WLED"), product=data.get("product", "FOSS"), mac=data.get("mac", ""), ip=data.get("ip", ""), + rgbw=leds_info.get("rgbw", False), # Auto-detect RGBW vs RGB + color_order=color_order, # Auto-detect color order (0=GRB, 1=RGB, etc.) ) except Exception as e: @@ -215,10 +279,12 @@ class WLEDClient: ) -> bool: """Send pixel colors to WLED device. + Uses DDP for large LED counts (>500), HTTP JSON API for smaller counts. + Args: pixels: List of (R, G, B) tuples for each LED brightness: Global brightness (0-255) - segment_id: Segment ID to update + segment_id: Segment ID to update (HTTP only) Returns: True if successful @@ -234,39 +300,98 @@ class WLEDClient: if not 0 <= brightness <= 255: raise ValueError(f"Brightness must be 0-255, got {brightness}") - # Validate and convert pixel values to Python ints (handles numpy types) - # WLED expects a flat array: [r1, g1, b1, r2, g2, b2, ...] - flat_pixels = [] + # Validate pixel values + validated_pixels = [] for i, (r, g, b) in enumerate(pixels): if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255): raise ValueError(f"Invalid RGB values at index {i}: ({r}, {g}, {b})") - # Convert to Python int and flatten to [r, g, b, r, g, b, ...] - flat_pixels.extend([int(r), int(g), int(b)]) + validated_pixels.append((int(r), int(g), int(b))) - # Build WLED JSON state with flat pixel array - payload = { - "on": True, - "bri": int(brightness), # Ensure brightness is also a Python int - "seg": [ - { - "id": segment_id, - "i": flat_pixels, # Individual LED colors as flat array - } - ], - } + # Use DDP protocol if enabled + if self.use_ddp and self._ddp_client: + return await self._send_pixels_ddp(validated_pixels, brightness) + else: + return await self._send_pixels_http(validated_pixels, brightness, segment_id) - # Log payload details for debugging - logger.debug(f"Sending {len(pixels)} LEDs ({len(flat_pixels)} values) to WLED") - logger.debug(f"Payload size: ~{len(str(payload))} bytes") - logger.debug(f"First 3 LEDs: {flat_pixels[:9] if len(flat_pixels) >= 9 else flat_pixels}") + async def _send_pixels_ddp( + self, + pixels: List[Tuple[int, int, int]], + brightness: int = 255, + ) -> bool: + """Send pixels via DDP protocol. + Args: + pixels: List of (R, G, B) tuples + brightness: Global brightness (0-255) + + Returns: + True if successful + """ try: - await self._request("POST", "/json/state", json_data=payload) - logger.debug(f"Successfully sent pixel colors to WLED device") + # Apply brightness to pixels + if brightness < 255: + brightness_factor = brightness / 255.0 + pixels = [ + ( + int(r * brightness_factor), + int(g * brightness_factor), + int(b * brightness_factor) + ) + for r, g, b in pixels + ] + + logger.debug(f"Sending {len(pixels)} LEDs via DDP") + await self._ddp_client.send_pixels(pixels) + logger.debug(f"Successfully sent pixel colors via DDP") return True except Exception as e: - logger.error(f"Failed to send pixels: {e}") + logger.error(f"Failed to send pixels via DDP: {e}") + raise + + async def _send_pixels_http( + self, + pixels: List[Tuple[int, int, int]], + brightness: int = 255, + segment_id: int = 0, + ) -> bool: + """Send pixels via HTTP JSON API. + + Args: + pixels: List of (R, G, B) tuples + brightness: Global brightness (0-255) + segment_id: Segment ID to update + + Returns: + True if successful + """ + try: + # Build indexed pixel array: [led_index, r, g, b, ...] + indexed_pixels = [] + for i, (r, g, b) in enumerate(pixels): + indexed_pixels.extend([i, int(r), int(g), int(b)]) + + # Build WLED JSON state + payload = { + "on": True, + "bri": int(brightness), + "seg": [ + { + "id": segment_id, + "i": indexed_pixels, + } + ], + } + + logger.debug(f"Sending {len(pixels)} LEDs via HTTP ({len(indexed_pixels)} values)") + logger.debug(f"Payload size: ~{len(str(payload))} bytes") + + await self._request("POST", "/json/state", json_data=payload) + logger.debug(f"Successfully sent pixel colors via HTTP") + return True + + except Exception as e: + logger.error(f"Failed to send pixels via HTTP: {e}") raise async def set_power(self, on: bool) -> bool: diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 4373a27..049a56b 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -2,6 +2,9 @@ const API_BASE = '/api/v1'; let refreshInterval = null; let apiKey = null; +// Track logged errors to avoid console spam +const loggedErrors = new Map(); // deviceId -> { errorCount, lastError } + // Locale management let currentLocale = 'en'; let translations = {}; @@ -181,6 +184,12 @@ function handle401Error() { localStorage.removeItem('wled_api_key'); apiKey = null; + // Stop auto-refresh to prevent repeated 401 errors + if (refreshInterval) { + clearInterval(refreshInterval); + refreshInterval = null; + } + if (typeof updateAuthUI === 'function') { updateAuthUI(); } @@ -392,8 +401,14 @@ async function loadDevices() { }); const metrics = await metricsResponse.json(); - // Log device info, especially if there are errors - if (metrics.errors_count > 0) { + // Log device errors only when they change (avoid console spam) + const deviceKey = device.id; + const lastLogged = loggedErrors.get(deviceKey); + const hasNewErrors = !lastLogged || + lastLogged.errorCount !== metrics.errors_count || + lastLogged.lastError !== metrics.last_error; + + if (metrics.errors_count > 0 && hasNewErrors) { console.warn(`[Device: ${device.name || device.id}] Has ${metrics.errors_count} error(s)`); // Log recent errors from state @@ -412,6 +427,16 @@ async function loadDevices() { // Log full state and metrics for debugging console.log('Full state:', state); console.log('Full metrics:', metrics); + + // Update tracking + loggedErrors.set(deviceKey, { + errorCount: metrics.errors_count, + lastError: metrics.last_error + }); + } else if (metrics.errors_count === 0 && lastLogged) { + // Clear tracking when errors are resolved + console.log(`[Device: ${device.name || device.id}] Errors cleared`); + loggedErrors.delete(deviceKey); } return { ...device, state, metrics }; @@ -763,7 +788,10 @@ function startAutoRefresh() { } refreshInterval = setInterval(() => { - loadDevices(); + // Only refresh if user is authenticated + if (apiKey) { + loadDevices(); + } }, 2000); // Refresh every 2 seconds } @@ -840,6 +868,7 @@ async function showCalibration(deviceId) { // Set layout document.getElementById('cal-start-position').value = calibration.start_position; document.getElementById('cal-layout').value = calibration.layout; + document.getElementById('cal-offset').value = calibration.offset || 0; // Set LED counts per edge const edgeCounts = { top: 0, right: 0, bottom: 0, left: 0 }; @@ -984,9 +1013,12 @@ async function saveCalibration() { } }); + const offset = parseInt(document.getElementById('cal-offset').value || 0); + const calibration = { layout: layout, start_position: startPosition, + offset: offset, segments: segments }; diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 3f99842..3615435 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -158,6 +158,12 @@ + +