Simplify calibration model, add pixel preview, and improve UI
Some checks failed
Validate / validate (push) Failing after 9s

- Replace segment-based calibration with core parameters (leds_top/right/bottom/left);
  segments are now derived at runtime via lookup tables
- Fix clockwise/counterclockwise edge traversal order for all 8 start_position/layout
  combinations (e.g. bottom_left+clockwise now correctly goes up-left first)
- Add pixel layout preview overlay with color-coded edges, LED index labels,
  direction arrows, and start position marker
- Move "Add New Device" form into a modal dialog triggered by "+" button
- Add display index selector to device settings modal
- Migrate from requirements.txt to pyproject.toml for dependency management
- Update Dockerfile and docs to use `pip install .`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 03:05:09 +03:00
parent d4261d76d8
commit 7f613df362
15 changed files with 965 additions and 317 deletions

View File

@@ -39,7 +39,7 @@ Complete installation guide for WLED Screen Controller server and Home Assistant
3. **Install dependencies:** 3. **Install dependencies:**
```bash ```bash
pip install -r requirements.txt pip install .
``` ```
4. **Configure (optional):** 4. **Configure (optional):**

View File

@@ -42,7 +42,7 @@ This project consists of two components:
2. **Install dependencies** 2. **Install dependencies**
```bash ```bash
pip install -r requirements.txt pip install .
``` ```
3. **Run the server** 3. **Run the server**
@@ -150,7 +150,7 @@ wled-screen-controller/
│ ├── src/wled_controller/ # Main application code │ ├── src/wled_controller/ # Main application code
│ ├── tests/ # Unit and integration tests │ ├── tests/ # Unit and integration tests
│ ├── config/ # Configuration files │ ├── config/ # Configuration files
│ └── requirements.txt # Python dependencies │ └── pyproject.toml # Python dependencies & project config
├── homeassistant/ # Home Assistant integration ├── homeassistant/ # Home Assistant integration
│ └── custom_components/ │ └── custom_components/
└── docs/ # Documentation └── docs/ # Documentation

View File

@@ -14,13 +14,11 @@ RUN apt-get update && apt-get install -y \
libxcb-shape0 \ libxcb-shape0 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies # Copy project files and install Python dependencies
COPY requirements.txt . COPY pyproject.toml .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY src/ ./src/ COPY src/ ./src/
COPY config/ ./config/ COPY config/ ./config/
RUN pip install --no-cache-dir .
# Create directories for data and logs # Create directories for data and logs
RUN mkdir -p /app/data /app/logs RUN mkdir -p /app/data /app/logs

View File

@@ -40,7 +40,7 @@ source venv/bin/activate # Linux/Mac
venv\Scripts\activate # Windows venv\Scripts\activate # Windows
# Install dependencies # Install dependencies
pip install -r requirements.txt pip install .
# Set PYTHONPATH # Set PYTHONPATH
export PYTHONPATH=$(pwd)/src # Linux/Mac export PYTHONPATH=$(pwd)/src # Linux/Mac

View File

@@ -35,6 +35,8 @@ dependencies = [
"structlog>=24.4.0", "structlog>=24.4.0",
"python-json-logger>=3.1.0", "python-json-logger>=3.1.0",
"python-dateutil>=2.9.0", "python-dateutil>=2.9.0",
"python-multipart>=0.0.12",
"wmi>=1.5.1; sys_platform == 'win32'",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -48,9 +50,9 @@ dev = [
] ]
[project.urls] [project.urls]
Homepage = "https://github.com/yourusername/wled-screen-controller" Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
Repository = "https://github.com/yourusername/wled-screen-controller" Repository = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
Issues = "https://github.com/yourusername/wled-screen-controller/issues" Issues = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues"
[tool.setuptools] [tool.setuptools]
package-dir = {"" = "src"} package-dir = {"" = "src"}

View File

@@ -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

View File

@@ -309,7 +309,6 @@ async def update_device(
device_id=device_id, device_id=device_id,
name=update_data.name, name=update_data.name,
url=update_data.url, url=update_data.url,
led_count=update_data.led_count,
enabled=update_data.enabled, enabled=update_data.enabled,
) )

View File

@@ -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): class Calibration(BaseModel):
"""Calibration configuration for pixel-to-LED mapping.""" """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( start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = Field(
default="bottom_left", default="bottom_left",
description="Position of LED index 0" description="Starting corner of the LED strip"
) )
offset: int = Field( offset: int = Field(
default=0, default=0,
ge=0, ge=0,
description="Number of LEDs from physical LED 0 to start corner (along strip direction)" description="Number of LEDs from physical LED 0 to start corner (along strip direction)"
) )
segments: List[CalibrationSegment] = Field( leds_top: int = Field(default=0, ge=0, description="Number of LEDs on the top edge")
description="LED segments for each screen edge", leds_right: int = Field(default=0, ge=0, description="Number of LEDs on the right edge")
min_length=1, leds_bottom: int = Field(default=0, ge=0, description="Number of LEDs on the bottom edge")
max_length=4 leds_left: int = Field(default=0, ge=0, description="Number of LEDs on the left edge")
)
class CalibrationTestModeRequest(BaseModel): class CalibrationTestModeRequest(BaseModel):

View File

@@ -1,7 +1,7 @@
"""Calibration system for mapping screen pixels to LED positions.""" """Calibration system for mapping screen pixels to LED positions."""
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import List, Literal, Tuple from typing import Dict, List, Literal, Tuple
from wled_controller.core.screen_capture import ( from wled_controller.core.screen_capture import (
BorderPixels, BorderPixels,
@@ -14,6 +14,31 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__) 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 @dataclass
class CalibrationSegment: class CalibrationSegment:
@@ -27,12 +52,52 @@ class CalibrationSegment:
@dataclass @dataclass
class CalibrationConfig: 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"] layout: Literal["clockwise", "counterclockwise"]
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"]
segments: List[CalibrationSegment] offset: int = 0
offset: int = 0 # Physical LED offset from start corner (number of LEDs from LED 0 to start corner) 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: def validate(self) -> bool:
"""Validate calibration configuration. """Validate calibration configuration.
@@ -43,42 +108,20 @@ class CalibrationConfig:
Raises: Raises:
ValueError: If configuration is invalid ValueError: If configuration is invalid
""" """
if not self.segments: total = self.get_total_leds()
raise ValueError("Calibration must have at least one segment") if total <= 0:
raise ValueError("Calibration must have at least one LED")
# Check for duplicate edges for edge, count in [("top", self.leds_top), ("right", self.leds_right),
edges = [seg.edge for seg in self.segments] ("bottom", self.leds_bottom), ("left", self.leds_left)]:
if len(edges) != len(set(edges)): if count < 0:
raise ValueError("Duplicate edges in calibration segments") raise ValueError(f"LED count for {edge} must be non-negative, got {count}")
# 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}")
return True return True
def get_total_leds(self) -> int: def get_total_leds(self) -> int:
"""Get total number of LEDs across all segments.""" """Get total number of LEDs across all edges."""
return sum(seg.led_count for seg in self.segments) return self.leds_top + self.leds_right + self.leds_bottom + self.leds_left
def get_segment_for_edge(self, edge: str) -> CalibrationSegment | None: def get_segment_for_edge(self, edge: str) -> CalibrationSegment | None:
"""Get segment configuration for a specific edge.""" """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) top_count = leds_per_edge + (1 if remainder > 1 else 0)
left_count = leds_per_edge + (1 if remainder > 2 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( config = CalibrationConfig(
layout="clockwise", layout="clockwise",
start_position="bottom_left", start_position="bottom_left",
segments=segments, leds_bottom=bottom_count,
leds_right=right_count,
leds_top=top_count,
leds_left=left_count,
) )
logger.info( logger.info(
@@ -293,6 +312,9 @@ def create_default_calibration(led_count: int) -> CalibrationConfig:
def calibration_from_dict(data: dict) -> CalibrationConfig: def calibration_from_dict(data: dict) -> CalibrationConfig:
"""Create calibration configuration from dictionary. """Create calibration configuration from dictionary.
Supports both new format (leds_top/right/bottom/left) and legacy format
(segments list) for backward compatibility.
Args: Args:
data: Dictionary with calibration data data: Dictionary with calibration data
@@ -303,21 +325,14 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
ValueError: If data is invalid ValueError: If data is invalid
""" """
try: 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( config = CalibrationConfig(
layout=data["layout"], layout=data["layout"],
start_position=data["start_position"], start_position=data["start_position"],
segments=segments,
offset=data.get("offset", 0), 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() config.validate()
@@ -326,6 +341,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
except KeyError as e: except KeyError as e:
raise ValueError(f"Missing required calibration field: {e}") raise ValueError(f"Missing required calibration field: {e}")
except Exception as e: except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"Invalid calibration data: {e}") raise ValueError(f"Invalid calibration data: {e}")
@@ -342,13 +359,8 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
"layout": config.layout, "layout": config.layout,
"start_position": config.start_position, "start_position": config.start_position,
"offset": config.offset, "offset": config.offset,
"segments": [ "leds_top": config.leds_top,
{ "leds_right": config.leds_right,
"edge": seg.edge, "leds_bottom": config.leds_bottom,
"led_start": seg.led_start, "leds_left": config.leds_left,
"led_count": seg.led_count,
"reverse": seg.reverse,
}
for seg in config.segments
],
} }

View File

@@ -562,6 +562,9 @@ function createDeviceCard(device) {
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}"> <button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
📐 📐
</button> </button>
<button class="btn btn-icon btn-secondary" onclick="showPixelPreview('${device.id}')" title="${t('preview.button')}">
👁️
</button>
<button class="btn btn-icon btn-danger" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}"> <button class="btn btn-icon btn-danger" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">
🗑️ 🗑️
</button> </button>
@@ -663,34 +666,55 @@ async function removeDevice(deviceId) {
async function showSettings(deviceId) { async function showSettings(deviceId) {
try { try {
// Fetch current device data // Fetch device data and displays in parallel
const response = await fetch(`${API_BASE}/devices/${deviceId}`, { const [deviceResponse, displaysResponse] = await Promise.all([
headers: getHeaders() fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
}); fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
]);
if (response.status === 401) { if (deviceResponse.status === 401) {
handle401Error(); handle401Error();
return; return;
} }
if (!response.ok) { if (!deviceResponse.ok) {
showToast('Failed to load device settings', 'error'); showToast('Failed to load device settings', 'error');
return; 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-id').value = device.id;
document.getElementById('settings-device-name').value = device.name; document.getElementById('settings-device-name').value = device.name;
document.getElementById('settings-device-url').value = device.url; document.getElementById('settings-device-url').value = device.url;
// Set health check interval
document.getElementById('settings-health-interval').value = device.settings.state_check_interval || 30; document.getElementById('settings-health-interval').value = device.settings.state_check_interval || 30;
// Snapshot initial values for dirty checking // Snapshot initial values for dirty checking
settingsInitialValues = { settingsInitialValues = {
name: device.name, name: device.name,
url: device.url, url: device.url,
display_index: String(device.settings.display_index ?? 0),
state_check_interval: String(device.settings.state_check_interval || 30), state_check_interval: String(device.settings.state_check_interval || 30),
}; };
@@ -714,6 +738,7 @@ function isSettingsDirty() {
return ( return (
document.getElementById('settings-device-name').value !== settingsInitialValues.name || document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
document.getElementById('settings-device-url').value !== settingsInitialValues.url || 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 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 deviceId = document.getElementById('settings-device-id').value;
const name = document.getElementById('settings-device-name').value.trim(); const name = document.getElementById('settings-device-name').value.trim();
const url = document.getElementById('settings-device-url').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 state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
const error = document.getElementById('settings-error'); const error = document.getElementById('settings-error');
@@ -773,7 +799,7 @@ async function saveDeviceSettings() {
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify({ state_check_interval }) body: JSON.stringify({ display_index, state_check_interval })
}); });
if (settingsResponse.status === 401) { 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) { async function handleAddDevice(event) {
event.preventDefault(); event.preventDefault();
const name = document.getElementById('device-name').value; const name = document.getElementById('device-name').value.trim();
const url = document.getElementById('device-url').value; 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 { try {
const response = await fetch(`${API_BASE}/devices`, { const response = await fetch(`${API_BASE}/devices`, {
@@ -842,15 +890,16 @@ async function handleAddDevice(event) {
const result = await response.json(); const result = await response.json();
console.log('Device added successfully:', result); console.log('Device added successfully:', result);
showToast('Device added successfully', 'success'); showToast('Device added successfully', 'success');
event.target.reset(); closeAddDeviceModal();
loadDevices(); loadDevices();
} else { } else {
const error = await response.json(); const errorData = await response.json();
console.error('Failed to add device:', error); console.error('Failed to add device:', errorData);
showToast(`Failed to add device: ${error.detail}`, 'error'); error.textContent = `Failed to add device: ${errorData.detail}`;
error.style.display = 'block';
} }
} catch (error) { } catch (err) {
console.error('Failed to add device:', error); console.error('Failed to add device:', err);
showToast('Failed to add device', 'error'); showToast('Failed to add device', 'error');
} }
} }
@@ -945,25 +994,20 @@ async function showCalibration(deviceId) {
document.getElementById('cal-offset').value = calibration.offset || 0; document.getElementById('cal-offset').value = calibration.offset || 0;
// Set LED counts per edge // Set LED counts per edge
const edgeCounts = { top: 0, right: 0, bottom: 0, left: 0 }; document.getElementById('cal-top-leds').value = calibration.leds_top || 0;
calibration.segments.forEach(seg => { document.getElementById('cal-right-leds').value = calibration.leds_right || 0;
edgeCounts[seg.edge] = seg.led_count; document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0;
}); document.getElementById('cal-left-leds').value = calibration.leds_left || 0;
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;
// Snapshot initial values for dirty checking // Snapshot initial values for dirty checking
calibrationInitialValues = { calibrationInitialValues = {
start_position: calibration.start_position, start_position: calibration.start_position,
layout: calibration.layout, layout: calibration.layout,
offset: String(calibration.offset || 0), offset: String(calibration.offset || 0),
top: String(edgeCounts.top), top: String(calibration.leds_top || 0),
right: String(edgeCounts.right), right: String(calibration.leds_right || 0),
bottom: String(edgeCounts.bottom), bottom: String(calibration.leds_bottom || 0),
left: String(edgeCounts.left), left: String(calibration.leds_left || 0),
}; };
// Initialize test mode state for this device // Initialize test mode state for this device
@@ -1167,40 +1211,16 @@ async function saveCalibration() {
// Build calibration config // Build calibration config
const startPosition = document.getElementById('cal-start-position').value; const startPosition = document.getElementById('cal-start-position').value;
const layout = document.getElementById('cal-layout').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 offset = parseInt(document.getElementById('cal-offset').value || 0);
const calibration = { const calibration = {
layout: layout, layout: layout,
start_position: startPosition, start_position: startPosition,
offset: offset, offset: offset,
segments: segments leds_top: topLeds,
leds_right: rightLeds,
leds_bottom: bottomLeds,
leds_left: leftLeds
}; };
try { try {
@@ -1232,43 +1252,465 @@ async function saveCalibration() {
} }
function getEdgeOrder(startPosition, layout) { function getEdgeOrder(startPosition, layout) {
const clockwise = ['bottom', 'right', 'top', 'left'];
const counterclockwise = ['bottom', 'left', 'top', 'right'];
const orders = { const orders = {
'bottom_left_clockwise': clockwise, 'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'],
'bottom_left_counterclockwise': counterclockwise, 'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'],
'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'], '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_clockwise': ['top', 'right', 'bottom', 'left'],
'top_left_counterclockwise': ['top', 'left', 'bottom', 'right'], 'top_left_counterclockwise': ['left', 'bottom', 'right', 'top'],
'top_right_clockwise': ['top', 'left', 'bottom', 'right'], 'top_right_clockwise': ['right', 'bottom', 'left', 'top'],
'top_right_counterclockwise': ['top', 'right', 'bottom', 'left'] '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) { function shouldReverse(edge, startPosition, layout) {
// Determine if this edge should be reversed based on LED strip direction // Determine if this edge should be reversed based on LED strip direction
const reverseRules = { 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_left_counterclockwise': { bottom: false, right: true, top: true, left: false },
'bottom_right_clockwise': { bottom: true, right: false, top: false, left: true }, 'bottom_right_clockwise': { bottom: true, left: true, top: false, right: false },
'bottom_right_counterclockwise': { bottom: true, right: true, top: false, left: 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_clockwise': { top: false, right: false, bottom: true, left: true },
'top_left_counterclockwise': { top: false, right: true, bottom: true, left: false }, 'top_left_counterclockwise': { left: false, bottom: false, right: true, top: true },
'top_right_clockwise': { top: true, right: false, bottom: false, left: true }, 'top_right_clockwise': { right: false, bottom: true, left: true, top: false },
'top_right_counterclockwise': { top: true, right: true, bottom: false, left: false } 'top_right_counterclockwise': { top: true, left: false, bottom: false, right: true }
}; };
const rules = reverseRules[`${startPosition}_${layout}`]; const rules = reverseRules[`${startPosition}_${layout}`];
return rules ? rules[edge] : false; 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 `<div class="pixel-preview-legend-item">
<div class="pixel-preview-legend-swatch" style="background: rgb(${r},${g},${b})"></div>
${edgeNames[seg.edge] || seg.edge}: ${seg.led_count} LEDs (#${seg.led_start}\u2013${last})
</div>`;
});
const dirText = calibration.layout === 'clockwise' ? t('preview.direction.cw') : t('preview.direction.ccw');
items.push(`<div class="pixel-preview-legend-item">
${calibration.layout === 'clockwise' ? '\u21BB' : '\u21BA'} ${dirText}
</div>`);
if (calibration.offset > 0) {
items.push(`<div class="pixel-preview-legend-item">
\u2194 ${t('preview.offset_leds', { count: calibration.offset })}
</div>`);
}
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) => { document.addEventListener('click', (e) => {
if (!e.target.classList.contains('modal')) return; if (!e.target.classList.contains('modal')) return;
if (backdropMouseDownTarget !== e.target) return;
const modalId = e.target.id; const modalId = e.target.id;
@@ -1298,6 +1740,12 @@ document.addEventListener('click', (e) => {
closeCalibrationModal(); closeCalibrationModal();
return; return;
} }
// Add device modal: close on backdrop
if (modalId === 'add-device-modal') {
closeAddDeviceModal();
return;
}
}); });
// Cleanup on page unload // Cleanup on page unload

View File

@@ -45,32 +45,15 @@
</section> </section>
<section class="devices-section"> <section class="devices-section">
<h2 data-i18n="devices.title">WLED Devices</h2> <div class="section-header">
<h2 data-i18n="devices.title">WLED Devices</h2>
<button class="btn btn-icon btn-primary" onclick="showAddDevice()" data-i18n-title="devices.add" title="Add New Device">+</button>
</div>
<div id="devices-list" class="devices-grid"> <div id="devices-list" class="devices-grid">
<div class="loading" data-i18n="devices.loading">Loading devices...</div> <div class="loading" data-i18n="devices.loading">Loading devices...</div>
</div> </div>
</section> </section>
<section class="add-device-section">
<h2 data-i18n="devices.add">Add New Device</h2>
<div class="info-banner" style="margin-bottom: 20px; padding: 12px; background: rgba(33, 150, 243, 0.1); border-left: 4px solid #2196F3; border-radius: 4px;">
<strong><span data-i18n="devices.wled_config">📱 WLED Configuration:</span></strong> <span data-i18n="devices.wled_note">Configure your WLED device (effects, segments, color order, power limits, etc.) using the</span>
<a href="https://kno.wled.ge/" target="_blank" rel="noopener" style="color: #2196F3; text-decoration: underline;" data-i18n="devices.wled_link">official WLED app</a>.
<span data-i18n="devices.wled_note2">This controller sends pixel color data and controls brightness per device.</span>
</div>
<form id="add-device-form">
<div class="form-group">
<label for="device-name" data-i18n="device.name">Device Name:</label>
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
</div>
<div class="form-group">
<label for="device-url" data-i18n="device.url">WLED URL:</label>
<input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div>
<button type="submit" class="btn btn-primary" data-i18n="device.button.add">Add Device</button>
</form>
</section>
<footer class="app-footer"> <footer class="app-footer">
<div class="footer-content"> <div class="footer-content">
<p> <p>
@@ -192,6 +175,11 @@
<small class="input-hint" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small> <small class="input-hint" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
</div> </div>
<div class="form-group">
<label for="settings-display-index" data-i18n="settings.display_index">Display:</label>
<select id="settings-display-index"></select>
<small class="input-hint" data-i18n="settings.display_index.hint">Which screen to capture for this device</small>
</div>
<div class="form-group"> <div class="form-group">
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label> <label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
@@ -262,6 +250,48 @@
</div> </div>
</div> </div>
<!-- Add Device Modal -->
<div id="add-device-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 data-i18n="devices.add">Add New Device</h2>
</div>
<div class="modal-body">
<div class="info-banner" style="margin-bottom: 16px; padding: 12px; background: rgba(33, 150, 243, 0.1); border-left: 4px solid #2196F3; border-radius: 4px;">
<strong><span data-i18n="devices.wled_config">WLED Configuration:</span></strong> <span data-i18n="devices.wled_note">Configure your WLED device (effects, segments, color order, power limits, etc.) using the</span>
<a href="https://kno.wled.ge/" target="_blank" rel="noopener" style="color: #2196F3; text-decoration: underline;" data-i18n="devices.wled_link">official WLED app</a>.
<span data-i18n="devices.wled_note2">This controller sends pixel color data and controls brightness per device.</span>
</div>
<form id="add-device-form">
<div class="form-group">
<label for="device-name" data-i18n="device.name">Device Name:</label>
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
</div>
<div class="form-group">
<label for="device-url" data-i18n="device.url">WLED URL:</label>
<input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div>
<div id="add-device-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeAddDeviceModal()" data-i18n="calibration.button.cancel">Cancel</button>
<button class="btn btn-primary" onclick="document.getElementById('add-device-form').requestSubmit()" data-i18n="device.button.add">Add Device</button>
</div>
</div>
</div>
<!-- Pixel Layout Preview Overlay -->
<div id="pixel-preview-overlay" class="pixel-preview-overlay" style="display: none;">
<div class="pixel-preview-header">
<span class="pixel-preview-title" data-i18n="preview.title">Pixel Layout Preview</span>
<span id="pixel-preview-device-name" class="pixel-preview-device-name"></span>
<button class="pixel-preview-close" onclick="closePixelPreview()" title="Close">&#x2715;</button>
</div>
<canvas id="pixel-preview-canvas"></canvas>
<div class="pixel-preview-legend" id="pixel-preview-legend"></div>
</div>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
<script> <script>
// Initialize theme // Initialize theme

View File

@@ -77,6 +77,8 @@
"settings.brightness": "Brightness:", "settings.brightness": "Brightness:",
"settings.brightness.hint": "Global brightness for this WLED device (0-100%)", "settings.brightness.hint": "Global brightness for this WLED device (0-100%)",
"settings.url.hint": "IP address or hostname of your WLED device", "settings.url.hint": "IP address or hostname of your WLED device",
"settings.display_index": "Display:",
"settings.display_index.hint": "Which screen to capture for this device",
"settings.button.cancel": "Cancel", "settings.button.cancel": "Cancel",
"settings.health_interval": "Health Check Interval (s):", "settings.health_interval": "Health Check Interval (s):",
"settings.health_interval.hint": "How often to check the WLED device status (5-600 seconds)", "settings.health_interval.hint": "How often to check the WLED device status (5-600 seconds)",
@@ -106,6 +108,17 @@
"calibration.button.save": "Save", "calibration.button.save": "Save",
"calibration.saved": "Calibration saved successfully", "calibration.saved": "Calibration saved successfully",
"calibration.failed": "Failed to save calibration", "calibration.failed": "Failed to save calibration",
"preview.title": "Pixel Layout Preview",
"preview.button": "Preview",
"preview.start": "START",
"preview.offset_leds": "Offset: {count} LEDs",
"preview.direction.cw": "Clockwise",
"preview.direction.ccw": "Counterclockwise",
"preview.edge.top": "Top",
"preview.edge.right": "Right",
"preview.edge.bottom": "Bottom",
"preview.edge.left": "Left",
"preview.no_calibration": "No calibration data. Please calibrate the device first.",
"server.healthy": "Server online", "server.healthy": "Server online",
"server.offline": "Server offline", "server.offline": "Server offline",
"error.unauthorized": "Unauthorized - please login", "error.unauthorized": "Unauthorized - please login",

View File

@@ -77,6 +77,8 @@
"settings.brightness": "Яркость:", "settings.brightness": "Яркость:",
"settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)", "settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)",
"settings.url.hint": "IP адрес или имя хоста вашего WLED устройства", "settings.url.hint": "IP адрес или имя хоста вашего WLED устройства",
"settings.display_index": "Дисплей:",
"settings.display_index.hint": "Какой экран захватывать для этого устройства",
"settings.button.cancel": "Отмена", "settings.button.cancel": "Отмена",
"settings.health_interval": "Интервал Проверки (с):", "settings.health_interval": "Интервал Проверки (с):",
"settings.health_interval.hint": "Как часто проверять статус WLED устройства (5-600 секунд)", "settings.health_interval.hint": "Как часто проверять статус WLED устройства (5-600 секунд)",
@@ -106,6 +108,17 @@
"calibration.button.save": "Сохранить", "calibration.button.save": "Сохранить",
"calibration.saved": "Калибровка успешно сохранена", "calibration.saved": "Калибровка успешно сохранена",
"calibration.failed": "Не удалось сохранить калибровку", "calibration.failed": "Не удалось сохранить калибровку",
"preview.title": "Предпросмотр Расположения Пикселей",
"preview.button": "Предпросмотр",
"preview.start": "СТАРТ",
"preview.offset_leds": "Смещение: {count} LED",
"preview.direction.cw": "По часовой",
"preview.direction.ccw": "Против часовой",
"preview.edge.top": "Верх",
"preview.edge.right": "Право",
"preview.edge.bottom": "Низ",
"preview.edge.left": "Лево",
"preview.no_calibration": "Нет данных калибровки. Сначала откалибруйте устройство.",
"server.healthy": "Сервер онлайн", "server.healthy": "Сервер онлайн",
"server.offline": "Сервер офлайн", "server.offline": "Сервер офлайн",
"error.unauthorized": "Не авторизован - пожалуйста, войдите", "error.unauthorized": "Не авторизован - пожалуйста, войдите",

View File

@@ -164,6 +164,10 @@ section {
gap: 20px; gap: 20px;
} }
.devices-grid > .loading {
grid-column: 1 / -1;
}
.card { .card {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -487,11 +491,15 @@ section {
width: 100%; width: 100%;
} }
.add-device-section { .section-header {
background: var(--card-bg); display: flex;
border: 1px solid var(--border-color); align-items: center;
border-radius: 8px; gap: 10px;
padding: 20px; margin-bottom: 15px;
}
.section-header h2 {
margin-bottom: 0;
} }
.form-group { .form-group {
@@ -981,3 +989,92 @@ input:-webkit-autofill:focus {
width: 100%; width: 100%;
} }
} }
/* Pixel Layout Preview Overlay */
.pixel-preview-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #111111;
z-index: 3000;
display: flex;
flex-direction: column;
animation: fadeIn 0.2s ease-out;
}
.pixel-preview-header {
display: flex;
align-items: center;
padding: 12px 20px;
background: rgba(0, 0, 0, 0.6);
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.pixel-preview-title {
font-size: 1.1rem;
font-weight: 600;
color: #e0e0e0;
}
.pixel-preview-device-name {
font-size: 0.9rem;
color: #999;
margin-left: 12px;
}
.pixel-preview-close {
margin-left: auto;
background: none;
border: 1px solid #555;
color: #e0e0e0;
font-size: 1.2rem;
width: 36px;
height: 36px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, border-color 0.2s;
}
.pixel-preview-close:hover {
background: rgba(244, 67, 54, 0.3);
border-color: #f44336;
}
#pixel-preview-canvas {
flex: 1;
width: 100%;
min-height: 0;
}
.pixel-preview-legend {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 10px 20px;
background: rgba(0, 0, 0, 0.6);
border-top: 1px solid #333;
flex-shrink: 0;
flex-wrap: wrap;
}
.pixel-preview-legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: #ccc;
}
.pixel-preview-legend-swatch {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.3);
}

View File

@@ -10,6 +10,8 @@ from wled_controller.core.calibration import (
create_default_calibration, create_default_calibration,
calibration_from_dict, calibration_from_dict,
calibration_to_dict, calibration_to_dict,
EDGE_ORDER,
EDGE_REVERSE,
) )
from wled_controller.core.screen_capture import BorderPixels from wled_controller.core.screen_capture import BorderPixels
@@ -31,84 +33,50 @@ def test_calibration_segment():
def test_calibration_config_validation(): def test_calibration_config_validation():
"""Test calibration configuration validation.""" """Test calibration configuration validation."""
segments = [
CalibrationSegment(edge="bottom", led_start=0, led_count=40),
CalibrationSegment(edge="right", led_start=40, led_count=30),
CalibrationSegment(edge="top", led_start=70, led_count=40),
CalibrationSegment(edge="left", led_start=110, led_count=40),
]
config = CalibrationConfig( config = CalibrationConfig(
layout="clockwise", layout="clockwise",
start_position="bottom_left", start_position="bottom_left",
segments=segments, leds_bottom=40,
leds_right=30,
leds_top=40,
leds_left=40,
) )
assert config.validate() is True assert config.validate() is True
assert config.get_total_leds() == 150 assert config.get_total_leds() == 150
def test_calibration_config_duplicate_edges(): def test_calibration_config_all_zero_leds():
"""Test validation fails with duplicate edges.""" """Test validation fails when all LED counts are zero."""
segments = [
CalibrationSegment(edge="top", led_start=0, led_count=40),
CalibrationSegment(edge="top", led_start=40, led_count=40), # Duplicate
]
config = CalibrationConfig( config = CalibrationConfig(
layout="clockwise", layout="clockwise",
start_position="bottom_left", start_position="bottom_left",
segments=segments,
) )
with pytest.raises(ValueError, match="Duplicate edges"): with pytest.raises(ValueError, match="at least one LED"):
config.validate() config.validate()
def test_calibration_config_overlapping_indices(): def test_calibration_config_negative_led_count():
"""Test validation fails with overlapping LED indices.""" """Test validation fails with negative LED counts."""
segments = [
CalibrationSegment(edge="bottom", led_start=0, led_count=50),
CalibrationSegment(edge="right", led_start=40, led_count=30), # Overlaps
]
config = CalibrationConfig( config = CalibrationConfig(
layout="clockwise", layout="clockwise",
start_position="bottom_left", start_position="bottom_left",
segments=segments, leds_top=-5,
leds_bottom=40,
) )
with pytest.raises(ValueError, match="overlap"): with pytest.raises(ValueError, match="non-negative"):
config.validate()
def test_calibration_config_invalid_led_count():
"""Test validation fails with invalid LED counts."""
segments = [
CalibrationSegment(edge="top", led_start=0, led_count=0), # Invalid
]
config = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
segments=segments,
)
with pytest.raises(ValueError):
config.validate() config.validate()
def test_get_segment_for_edge(): def test_get_segment_for_edge():
"""Test getting segment by edge name.""" """Test getting segment by edge name."""
segments = [
CalibrationSegment(edge="bottom", led_start=0, led_count=40),
CalibrationSegment(edge="right", led_start=40, led_count=30),
]
config = CalibrationConfig( config = CalibrationConfig(
layout="clockwise", layout="clockwise",
start_position="bottom_left", start_position="bottom_left",
segments=segments, leds_bottom=40,
leds_right=30,
) )
bottom_seg = config.get_segment_for_edge("bottom") bottom_seg = config.get_segment_for_edge("bottom")
@@ -119,6 +87,100 @@ def test_get_segment_for_edge():
assert missing_seg is None assert missing_seg is None
def test_build_segments_basic():
"""Test that build_segments produces correct output."""
config = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
leds_bottom=10,
leds_right=20,
leds_top=10,
leds_left=20,
)
segments = config.build_segments()
assert len(segments) == 4
# Clockwise from bottom_left: left(up), top(right), right(down), bottom(left)
assert segments[0].edge == "left"
assert segments[0].led_start == 0
assert segments[0].led_count == 20
assert segments[0].reverse is True
assert segments[1].edge == "top"
assert segments[1].led_start == 20
assert segments[1].led_count == 10
assert segments[1].reverse is False
assert segments[2].edge == "right"
assert segments[2].led_start == 30
assert segments[2].led_count == 20
assert segments[2].reverse is False
assert segments[3].edge == "bottom"
assert segments[3].led_start == 50
assert segments[3].led_count == 10
assert segments[3].reverse is True
def test_build_segments_skips_zero_edges():
"""Test that edges with 0 LEDs are skipped."""
config = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
leds_right=288,
leds_top=358,
leds_left=288,
)
segments = config.build_segments()
assert len(segments) == 3
edges = [s.edge for s in segments]
assert "bottom" not in edges
@pytest.mark.parametrize("start_position,layout", [
("bottom_left", "clockwise"),
("bottom_left", "counterclockwise"),
("bottom_right", "clockwise"),
("bottom_right", "counterclockwise"),
("top_left", "clockwise"),
("top_left", "counterclockwise"),
("top_right", "clockwise"),
("top_right", "counterclockwise"),
])
def test_build_segments_all_combinations(start_position, layout):
"""Test build_segments matches lookup tables for all 8 combinations."""
config = CalibrationConfig(
layout=layout,
start_position=start_position,
leds_top=10,
leds_right=10,
leds_bottom=10,
leds_left=10,
)
segments = config.build_segments()
assert len(segments) == 4
# Verify edge order matches EDGE_ORDER table
expected_order = EDGE_ORDER[(start_position, layout)]
actual_order = [s.edge for s in segments]
assert actual_order == expected_order
# Verify reverse flags match EDGE_REVERSE table
expected_reverse = EDGE_REVERSE[(start_position, layout)]
for seg in segments:
assert seg.reverse == expected_reverse[seg.edge], \
f"Mismatch for {start_position}/{layout}/{seg.edge}: expected reverse={expected_reverse[seg.edge]}"
# Verify led_start values are cumulative
expected_start = 0
for seg in segments:
assert seg.led_start == expected_start
expected_start += seg.led_count
def test_pixel_mapper_initialization(): def test_pixel_mapper_initialization():
"""Test pixel mapper initialization.""" """Test pixel mapper initialization."""
config = create_default_calibration(150) config = create_default_calibration(150)
@@ -156,12 +218,13 @@ def test_pixel_mapper_map_border_to_leds():
# Verify colors are reasonable (allowing for some rounding) # Verify colors are reasonable (allowing for some rounding)
# Bottom LEDs should be mostly blue # Bottom LEDs should be mostly blue
bottom_color = led_colors[0] bottom_seg = config.get_segment_for_edge("bottom")
bottom_color = led_colors[bottom_seg.led_start]
assert bottom_color[2] > 200 # Blue channel high assert bottom_color[2] > 200 # Blue channel high
# Top LEDs should be mostly red # Top LEDs should be mostly red
top_segment = config.get_segment_for_edge("top") top_seg = config.get_segment_for_edge("top")
top_color = led_colors[top_segment.led_start] top_color = led_colors[top_seg.led_start]
assert top_color[0] > 200 # Red channel high assert top_color[0] > 200 # Red channel high
@@ -190,9 +253,7 @@ def test_pixel_mapper_test_calibration_invalid_edge():
config = CalibrationConfig( config = CalibrationConfig(
layout="clockwise", layout="clockwise",
start_position="bottom_left", start_position="bottom_left",
segments=[ leds_bottom=40,
CalibrationSegment(edge="bottom", led_start=0, led_count=40),
],
) )
mapper = PixelMapper(config) mapper = PixelMapper(config)
@@ -209,7 +270,13 @@ def test_create_default_calibration():
assert len(config.segments) == 4 assert len(config.segments) == 4
assert config.get_total_leds() == 150 assert config.get_total_leds() == 150
# Check all edges are present # Check all edges have LEDs
assert config.leds_top > 0
assert config.leds_right > 0
assert config.leds_bottom > 0
assert config.leds_left > 0
# Check all edges are present in derived segments
edges = {seg.edge for seg in config.segments} edges = {seg.edge for seg in config.segments}
assert edges == {"top", "right", "bottom", "left"} assert edges == {"top", "right", "bottom", "left"}
@@ -227,22 +294,28 @@ def test_create_default_calibration_invalid():
def test_calibration_from_dict(): def test_calibration_from_dict():
"""Test creating calibration from dictionary.""" """Test creating calibration from new format dictionary."""
data = { data = {
"layout": "clockwise", "layout": "clockwise",
"start_position": "bottom_left", "start_position": "bottom_left",
"segments": [ "offset": 5,
{"edge": "bottom", "led_start": 0, "led_count": 40, "reverse": False}, "leds_top": 40,
{"edge": "right", "led_start": 40, "led_count": 30, "reverse": False}, "leds_right": 30,
], "leds_bottom": 40,
"leds_left": 30,
} }
config = calibration_from_dict(data) config = calibration_from_dict(data)
assert config.layout == "clockwise" assert config.layout == "clockwise"
assert config.start_position == "bottom_left" assert config.start_position == "bottom_left"
assert len(config.segments) == 2 assert config.offset == 5
assert config.get_total_leds() == 70 assert config.leds_top == 40
assert config.leds_right == 30
assert config.leds_bottom == 40
assert config.leds_left == 30
assert config.get_total_leds() == 140
def test_calibration_from_dict_missing_field(): def test_calibration_from_dict_missing_field():
@@ -250,7 +323,7 @@ def test_calibration_from_dict_missing_field():
data = { data = {
"layout": "clockwise", "layout": "clockwise",
# Missing start_position # Missing start_position
"segments": [], "leds_top": 10,
} }
with pytest.raises(ValueError): with pytest.raises(ValueError):
@@ -264,9 +337,12 @@ def test_calibration_to_dict():
assert "layout" in data assert "layout" in data
assert "start_position" in data assert "start_position" in data
assert "segments" in data assert "leds_top" in data
assert isinstance(data["segments"], list) assert "leds_right" in data
assert len(data["segments"]) == 4 assert "leds_bottom" in data
assert "leds_left" in data
assert "segments" not in data
assert data["leds_top"] + data["leds_right"] + data["leds_bottom"] + data["leds_left"] == 100
def test_calibration_round_trip(): def test_calibration_round_trip():
@@ -277,5 +353,9 @@ def test_calibration_round_trip():
assert restored.layout == original.layout assert restored.layout == original.layout
assert restored.start_position == original.start_position assert restored.start_position == original.start_position
assert len(restored.segments) == len(original.segments) assert restored.leds_top == original.leds_top
assert restored.leds_right == original.leds_right
assert restored.leds_bottom == original.leds_bottom
assert restored.leds_left == original.leds_left
assert restored.get_total_leds() == original.get_total_leds() assert restored.get_total_leds() == original.get_total_leds()
assert len(restored.segments) == len(original.segments)