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

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

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):
"""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):

View File

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

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>
<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>
@@ -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 `<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) => {
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

View File

@@ -45,32 +45,15 @@
</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 class="loading" data-i18n="devices.loading">Loading devices...</div>
</div>
</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">
<div class="footer-content">
<p>
@@ -192,6 +175,11 @@
<small class="input-hint" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
</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">
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
@@ -262,6 +250,48 @@
</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>
// Initialize theme

View File

@@ -77,6 +77,8 @@
"settings.brightness": "Brightness:",
"settings.brightness.hint": "Global brightness for this WLED device (0-100%)",
"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.health_interval": "Health Check Interval (s):",
"settings.health_interval.hint": "How often to check the WLED device status (5-600 seconds)",
@@ -106,6 +108,17 @@
"calibration.button.save": "Save",
"calibration.saved": "Calibration saved successfully",
"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.offline": "Server offline",
"error.unauthorized": "Unauthorized - please login",

View File

@@ -77,6 +77,8 @@
"settings.brightness": "Яркость:",
"settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)",
"settings.url.hint": "IP адрес или имя хоста вашего WLED устройства",
"settings.display_index": "Дисплей:",
"settings.display_index.hint": "Какой экран захватывать для этого устройства",
"settings.button.cancel": "Отмена",
"settings.health_interval": "Интервал Проверки (с):",
"settings.health_interval.hint": "Как часто проверять статус WLED устройства (5-600 секунд)",
@@ -106,6 +108,17 @@
"calibration.button.save": "Сохранить",
"calibration.saved": "Калибровка успешно сохранена",
"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.offline": "Сервер офлайн",
"error.unauthorized": "Не авторизован - пожалуйста, войдите",

View File

@@ -164,6 +164,10 @@ section {
gap: 20px;
}
.devices-grid > .loading {
grid-column: 1 / -1;
}
.card {
background: var(--card-bg);
border: 1px solid var(--border-color);
@@ -487,11 +491,15 @@ section {
width: 100%;
}
.add-device-section {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
}
.section-header h2 {
margin-bottom: 0;
}
.form-group {
@@ -981,3 +989,92 @@ input:-webkit-autofill:focus {
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);
}