Simplify calibration model, add pixel preview, and improve UI
Some checks failed
Validate / validate (push) Failing after 9s
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:
@@ -39,7 +39,7 @@ Complete installation guide for WLED Screen Controller server and Home Assistant
|
||||
|
||||
3. **Install dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install .
|
||||
```
|
||||
|
||||
4. **Configure (optional):**
|
||||
|
||||
@@ -42,7 +42,7 @@ This project consists of two components:
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install .
|
||||
```
|
||||
|
||||
3. **Run the server**
|
||||
@@ -150,7 +150,7 @@ wled-screen-controller/
|
||||
│ ├── src/wled_controller/ # Main application code
|
||||
│ ├── tests/ # Unit and integration tests
|
||||
│ ├── config/ # Configuration files
|
||||
│ └── requirements.txt # Python dependencies
|
||||
│ └── pyproject.toml # Python dependencies & project config
|
||||
├── homeassistant/ # Home Assistant integration
|
||||
│ └── custom_components/
|
||||
└── docs/ # Documentation
|
||||
|
||||
@@ -14,13 +14,11 @@ RUN apt-get update && apt-get install -y \
|
||||
libxcb-shape0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
# Copy project files and install Python dependencies
|
||||
COPY pyproject.toml .
|
||||
COPY src/ ./src/
|
||||
COPY config/ ./config/
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# Create directories for data and logs
|
||||
RUN mkdir -p /app/data /app/logs
|
||||
|
||||
@@ -40,7 +40,7 @@ source venv/bin/activate # Linux/Mac
|
||||
venv\Scripts\activate # Windows
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
pip install .
|
||||
|
||||
# Set PYTHONPATH
|
||||
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||
|
||||
@@ -35,6 +35,8 @@ dependencies = [
|
||||
"structlog>=24.4.0",
|
||||
"python-json-logger>=3.1.0",
|
||||
"python-dateutil>=2.9.0",
|
||||
"python-multipart>=0.0.12",
|
||||
"wmi>=1.5.1; sys_platform == 'win32'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -48,9 +50,9 @@ dev = [
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/yourusername/wled-screen-controller"
|
||||
Repository = "https://github.com/yourusername/wled-screen-controller"
|
||||
Issues = "https://github.com/yourusername/wled-screen-controller/issues"
|
||||
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||
Repository = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||
Issues = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">✕</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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Не авторизован - пожалуйста, войдите",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ from wled_controller.core.calibration import (
|
||||
create_default_calibration,
|
||||
calibration_from_dict,
|
||||
calibration_to_dict,
|
||||
EDGE_ORDER,
|
||||
EDGE_REVERSE,
|
||||
)
|
||||
from wled_controller.core.screen_capture import BorderPixels
|
||||
|
||||
@@ -31,84 +33,50 @@ def test_calibration_segment():
|
||||
|
||||
def test_calibration_config_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(
|
||||
layout="clockwise",
|
||||
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.get_total_leds() == 150
|
||||
|
||||
|
||||
def test_calibration_config_duplicate_edges():
|
||||
"""Test validation fails with duplicate edges."""
|
||||
segments = [
|
||||
CalibrationSegment(edge="top", led_start=0, led_count=40),
|
||||
CalibrationSegment(edge="top", led_start=40, led_count=40), # Duplicate
|
||||
]
|
||||
|
||||
def test_calibration_config_all_zero_leds():
|
||||
"""Test validation fails when all LED counts are zero."""
|
||||
config = CalibrationConfig(
|
||||
layout="clockwise",
|
||||
start_position="bottom_left",
|
||||
segments=segments,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Duplicate edges"):
|
||||
with pytest.raises(ValueError, match="at least one LED"):
|
||||
config.validate()
|
||||
|
||||
|
||||
def test_calibration_config_overlapping_indices():
|
||||
"""Test validation fails with overlapping LED indices."""
|
||||
segments = [
|
||||
CalibrationSegment(edge="bottom", led_start=0, led_count=50),
|
||||
CalibrationSegment(edge="right", led_start=40, led_count=30), # Overlaps
|
||||
]
|
||||
|
||||
def test_calibration_config_negative_led_count():
|
||||
"""Test validation fails with negative LED counts."""
|
||||
config = CalibrationConfig(
|
||||
layout="clockwise",
|
||||
start_position="bottom_left",
|
||||
segments=segments,
|
||||
leds_top=-5,
|
||||
leds_bottom=40,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="overlap"):
|
||||
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):
|
||||
with pytest.raises(ValueError, match="non-negative"):
|
||||
config.validate()
|
||||
|
||||
|
||||
def test_get_segment_for_edge():
|
||||
"""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(
|
||||
layout="clockwise",
|
||||
start_position="bottom_left",
|
||||
segments=segments,
|
||||
leds_bottom=40,
|
||||
leds_right=30,
|
||||
)
|
||||
|
||||
bottom_seg = config.get_segment_for_edge("bottom")
|
||||
@@ -119,6 +87,100 @@ def test_get_segment_for_edge():
|
||||
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():
|
||||
"""Test pixel mapper initialization."""
|
||||
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)
|
||||
# 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
|
||||
|
||||
# Top LEDs should be mostly red
|
||||
top_segment = config.get_segment_for_edge("top")
|
||||
top_color = led_colors[top_segment.led_start]
|
||||
top_seg = config.get_segment_for_edge("top")
|
||||
top_color = led_colors[top_seg.led_start]
|
||||
assert top_color[0] > 200 # Red channel high
|
||||
|
||||
|
||||
@@ -190,9 +253,7 @@ def test_pixel_mapper_test_calibration_invalid_edge():
|
||||
config = CalibrationConfig(
|
||||
layout="clockwise",
|
||||
start_position="bottom_left",
|
||||
segments=[
|
||||
CalibrationSegment(edge="bottom", led_start=0, led_count=40),
|
||||
],
|
||||
leds_bottom=40,
|
||||
)
|
||||
mapper = PixelMapper(config)
|
||||
|
||||
@@ -209,7 +270,13 @@ def test_create_default_calibration():
|
||||
assert len(config.segments) == 4
|
||||
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}
|
||||
assert edges == {"top", "right", "bottom", "left"}
|
||||
|
||||
@@ -227,22 +294,28 @@ def test_create_default_calibration_invalid():
|
||||
|
||||
|
||||
def test_calibration_from_dict():
|
||||
"""Test creating calibration from dictionary."""
|
||||
"""Test creating calibration from new format dictionary."""
|
||||
data = {
|
||||
"layout": "clockwise",
|
||||
"start_position": "bottom_left",
|
||||
"segments": [
|
||||
{"edge": "bottom", "led_start": 0, "led_count": 40, "reverse": False},
|
||||
{"edge": "right", "led_start": 40, "led_count": 30, "reverse": False},
|
||||
],
|
||||
"offset": 5,
|
||||
"leds_top": 40,
|
||||
"leds_right": 30,
|
||||
"leds_bottom": 40,
|
||||
"leds_left": 30,
|
||||
}
|
||||
|
||||
config = calibration_from_dict(data)
|
||||
|
||||
assert config.layout == "clockwise"
|
||||
assert config.start_position == "bottom_left"
|
||||
assert len(config.segments) == 2
|
||||
assert config.get_total_leds() == 70
|
||||
assert config.offset == 5
|
||||
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():
|
||||
@@ -250,7 +323,7 @@ def test_calibration_from_dict_missing_field():
|
||||
data = {
|
||||
"layout": "clockwise",
|
||||
# Missing start_position
|
||||
"segments": [],
|
||||
"leds_top": 10,
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
@@ -264,9 +337,12 @@ def test_calibration_to_dict():
|
||||
|
||||
assert "layout" in data
|
||||
assert "start_position" in data
|
||||
assert "segments" in data
|
||||
assert isinstance(data["segments"], list)
|
||||
assert len(data["segments"]) == 4
|
||||
assert "leds_top" in data
|
||||
assert "leds_right" in data
|
||||
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():
|
||||
@@ -277,5 +353,9 @@ def test_calibration_round_trip():
|
||||
|
||||
assert restored.layout == original.layout
|
||||
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 len(restored.segments) == len(original.segments)
|
||||
|
||||
Reference in New Issue
Block a user