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:**
|
3. **Install dependencies:**
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install .
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Configure (optional):**
|
4. **Configure (optional):**
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ This project consists of two components:
|
|||||||
|
|
||||||
2. **Install dependencies**
|
2. **Install dependencies**
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install .
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Run the server**
|
3. **Run the server**
|
||||||
@@ -150,7 +150,7 @@ wled-screen-controller/
|
|||||||
│ ├── src/wled_controller/ # Main application code
|
│ ├── src/wled_controller/ # Main application code
|
||||||
│ ├── tests/ # Unit and integration tests
|
│ ├── tests/ # Unit and integration tests
|
||||||
│ ├── config/ # Configuration files
|
│ ├── config/ # Configuration files
|
||||||
│ └── requirements.txt # Python dependencies
|
│ └── pyproject.toml # Python dependencies & project config
|
||||||
├── homeassistant/ # Home Assistant integration
|
├── homeassistant/ # Home Assistant integration
|
||||||
│ └── custom_components/
|
│ └── custom_components/
|
||||||
└── docs/ # Documentation
|
└── docs/ # Documentation
|
||||||
|
|||||||
@@ -14,13 +14,11 @@ RUN apt-get update && apt-get install -y \
|
|||||||
libxcb-shape0 \
|
libxcb-shape0 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements and install Python dependencies
|
# Copy project files and install Python dependencies
|
||||||
COPY requirements.txt .
|
COPY pyproject.toml .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY config/ ./config/
|
COPY config/ ./config/
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
# Create directories for data and logs
|
# Create directories for data and logs
|
||||||
RUN mkdir -p /app/data /app/logs
|
RUN mkdir -p /app/data /app/logs
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ source venv/bin/activate # Linux/Mac
|
|||||||
venv\Scripts\activate # Windows
|
venv\Scripts\activate # Windows
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
pip install -r requirements.txt
|
pip install .
|
||||||
|
|
||||||
# Set PYTHONPATH
|
# Set PYTHONPATH
|
||||||
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ dependencies = [
|
|||||||
"structlog>=24.4.0",
|
"structlog>=24.4.0",
|
||||||
"python-json-logger>=3.1.0",
|
"python-json-logger>=3.1.0",
|
||||||
"python-dateutil>=2.9.0",
|
"python-dateutil>=2.9.0",
|
||||||
|
"python-multipart>=0.0.12",
|
||||||
|
"wmi>=1.5.1; sys_platform == 'win32'",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -48,9 +50,9 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/yourusername/wled-screen-controller"
|
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||||
Repository = "https://github.com/yourusername/wled-screen-controller"
|
Repository = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||||
Issues = "https://github.com/yourusername/wled-screen-controller/issues"
|
Issues = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-dir = {"" = "src"}
|
package-dir = {"" = "src"}
|
||||||
|
|||||||
@@ -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,
|
device_id=device_id,
|
||||||
name=update_data.name,
|
name=update_data.name,
|
||||||
url=update_data.url,
|
url=update_data.url,
|
||||||
led_count=update_data.led_count,
|
|
||||||
enabled=update_data.enabled,
|
enabled=update_data.enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -90,15 +90,6 @@ class ProcessingSettings(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalibrationSegment(BaseModel):
|
|
||||||
"""Calibration segment for LED mapping."""
|
|
||||||
|
|
||||||
edge: Literal["top", "right", "bottom", "left"] = Field(description="Screen edge")
|
|
||||||
led_start: int = Field(description="Starting LED index", ge=0)
|
|
||||||
led_count: int = Field(description="Number of LEDs on this edge", gt=0)
|
|
||||||
reverse: bool = Field(default=False, description="Reverse LED order on this edge")
|
|
||||||
|
|
||||||
|
|
||||||
class Calibration(BaseModel):
|
class Calibration(BaseModel):
|
||||||
"""Calibration configuration for pixel-to-LED mapping."""
|
"""Calibration configuration for pixel-to-LED mapping."""
|
||||||
|
|
||||||
@@ -108,18 +99,17 @@ class Calibration(BaseModel):
|
|||||||
)
|
)
|
||||||
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = Field(
|
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = Field(
|
||||||
default="bottom_left",
|
default="bottom_left",
|
||||||
description="Position of LED index 0"
|
description="Starting corner of the LED strip"
|
||||||
)
|
)
|
||||||
offset: int = Field(
|
offset: int = Field(
|
||||||
default=0,
|
default=0,
|
||||||
ge=0,
|
ge=0,
|
||||||
description="Number of LEDs from physical LED 0 to start corner (along strip direction)"
|
description="Number of LEDs from physical LED 0 to start corner (along strip direction)"
|
||||||
)
|
)
|
||||||
segments: List[CalibrationSegment] = Field(
|
leds_top: int = Field(default=0, ge=0, description="Number of LEDs on the top edge")
|
||||||
description="LED segments for each screen edge",
|
leds_right: int = Field(default=0, ge=0, description="Number of LEDs on the right edge")
|
||||||
min_length=1,
|
leds_bottom: int = Field(default=0, ge=0, description="Number of LEDs on the bottom edge")
|
||||||
max_length=4
|
leds_left: int = Field(default=0, ge=0, description="Number of LEDs on the left edge")
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CalibrationTestModeRequest(BaseModel):
|
class CalibrationTestModeRequest(BaseModel):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Calibration system for mapping screen pixels to LED positions."""
|
"""Calibration system for mapping screen pixels to LED positions."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import List, Literal, Tuple
|
from typing import Dict, List, Literal, Tuple
|
||||||
|
|
||||||
from wled_controller.core.screen_capture import (
|
from wled_controller.core.screen_capture import (
|
||||||
BorderPixels,
|
BorderPixels,
|
||||||
@@ -14,6 +14,31 @@ from wled_controller.utils import get_logger
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Edge traversal order for each (start_position, layout) combination.
|
||||||
|
# Determines which edge comes first when walking around the screen.
|
||||||
|
EDGE_ORDER: Dict[Tuple[str, str], List[str]] = {
|
||||||
|
("bottom_left", "clockwise"): ["left", "top", "right", "bottom"],
|
||||||
|
("bottom_left", "counterclockwise"): ["bottom", "right", "top", "left"],
|
||||||
|
("bottom_right", "clockwise"): ["bottom", "left", "top", "right"],
|
||||||
|
("bottom_right", "counterclockwise"): ["right", "top", "left", "bottom"],
|
||||||
|
("top_left", "clockwise"): ["top", "right", "bottom", "left"],
|
||||||
|
("top_left", "counterclockwise"): ["left", "bottom", "right", "top"],
|
||||||
|
("top_right", "clockwise"): ["right", "bottom", "left", "top"],
|
||||||
|
("top_right", "counterclockwise"): ["top", "left", "bottom", "right"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Whether LEDs are reversed on each edge for each (start_position, layout) combination.
|
||||||
|
EDGE_REVERSE: Dict[Tuple[str, str], Dict[str, bool]] = {
|
||||||
|
("bottom_left", "clockwise"): {"left": True, "top": False, "right": False, "bottom": True},
|
||||||
|
("bottom_left", "counterclockwise"): {"bottom": False, "right": True, "top": True, "left": False},
|
||||||
|
("bottom_right", "clockwise"): {"bottom": True, "left": True, "top": False, "right": False},
|
||||||
|
("bottom_right", "counterclockwise"): {"right": True, "top": True, "left": False, "bottom": False},
|
||||||
|
("top_left", "clockwise"): {"top": False, "right": False, "bottom": True, "left": True},
|
||||||
|
("top_left", "counterclockwise"): {"left": False, "bottom": False, "right": True, "top": True},
|
||||||
|
("top_right", "clockwise"): {"right": False, "bottom": True, "left": True, "top": False},
|
||||||
|
("top_right", "counterclockwise"): {"top": True, "left": False, "bottom": False, "right": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CalibrationSegment:
|
class CalibrationSegment:
|
||||||
@@ -27,12 +52,52 @@ class CalibrationSegment:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CalibrationConfig:
|
class CalibrationConfig:
|
||||||
"""Complete calibration configuration."""
|
"""Complete calibration configuration.
|
||||||
|
|
||||||
|
Stores only the core parameters. Segments (with led_start, reverse, edge order)
|
||||||
|
are derived at runtime via the `segments` property.
|
||||||
|
"""
|
||||||
|
|
||||||
layout: Literal["clockwise", "counterclockwise"]
|
layout: Literal["clockwise", "counterclockwise"]
|
||||||
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"]
|
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"]
|
||||||
segments: List[CalibrationSegment]
|
offset: int = 0
|
||||||
offset: int = 0 # Physical LED offset from start corner (number of LEDs from LED 0 to start corner)
|
leds_top: int = 0
|
||||||
|
leds_right: int = 0
|
||||||
|
leds_bottom: int = 0
|
||||||
|
leds_left: int = 0
|
||||||
|
|
||||||
|
def build_segments(self) -> List[CalibrationSegment]:
|
||||||
|
"""Derive segment list from core parameters."""
|
||||||
|
key = (self.start_position, self.layout)
|
||||||
|
edge_order = EDGE_ORDER.get(key, ["bottom", "right", "top", "left"])
|
||||||
|
reverse_map = EDGE_REVERSE.get(key, {})
|
||||||
|
|
||||||
|
led_counts = {
|
||||||
|
"top": self.leds_top,
|
||||||
|
"right": self.leds_right,
|
||||||
|
"bottom": self.leds_bottom,
|
||||||
|
"left": self.leds_left,
|
||||||
|
}
|
||||||
|
|
||||||
|
segments = []
|
||||||
|
led_start = 0
|
||||||
|
for edge in edge_order:
|
||||||
|
count = led_counts[edge]
|
||||||
|
if count > 0:
|
||||||
|
segments.append(CalibrationSegment(
|
||||||
|
edge=edge,
|
||||||
|
led_start=led_start,
|
||||||
|
led_count=count,
|
||||||
|
reverse=reverse_map.get(edge, False),
|
||||||
|
))
|
||||||
|
led_start += count
|
||||||
|
|
||||||
|
return segments
|
||||||
|
|
||||||
|
@property
|
||||||
|
def segments(self) -> List[CalibrationSegment]:
|
||||||
|
"""Get derived segment list."""
|
||||||
|
return self.build_segments()
|
||||||
|
|
||||||
def validate(self) -> bool:
|
def validate(self) -> bool:
|
||||||
"""Validate calibration configuration.
|
"""Validate calibration configuration.
|
||||||
@@ -43,42 +108,20 @@ class CalibrationConfig:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If configuration is invalid
|
ValueError: If configuration is invalid
|
||||||
"""
|
"""
|
||||||
if not self.segments:
|
total = self.get_total_leds()
|
||||||
raise ValueError("Calibration must have at least one segment")
|
if total <= 0:
|
||||||
|
raise ValueError("Calibration must have at least one LED")
|
||||||
|
|
||||||
# Check for duplicate edges
|
for edge, count in [("top", self.leds_top), ("right", self.leds_right),
|
||||||
edges = [seg.edge for seg in self.segments]
|
("bottom", self.leds_bottom), ("left", self.leds_left)]:
|
||||||
if len(edges) != len(set(edges)):
|
if count < 0:
|
||||||
raise ValueError("Duplicate edges in calibration segments")
|
raise ValueError(f"LED count for {edge} must be non-negative, got {count}")
|
||||||
|
|
||||||
# Validate LED indices don't overlap
|
|
||||||
led_ranges = []
|
|
||||||
for seg in self.segments:
|
|
||||||
led_range = range(seg.led_start, seg.led_start + seg.led_count)
|
|
||||||
led_ranges.append(led_range)
|
|
||||||
|
|
||||||
# Check for overlaps
|
|
||||||
for i, range1 in enumerate(led_ranges):
|
|
||||||
for j, range2 in enumerate(led_ranges):
|
|
||||||
if i != j:
|
|
||||||
overlap = set(range1) & set(range2)
|
|
||||||
if overlap:
|
|
||||||
raise ValueError(
|
|
||||||
f"LED indices overlap between segments {i} and {j}: {overlap}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate LED counts are positive
|
|
||||||
for seg in self.segments:
|
|
||||||
if seg.led_count <= 0:
|
|
||||||
raise ValueError(f"LED count must be positive, got {seg.led_count}")
|
|
||||||
if seg.led_start < 0:
|
|
||||||
raise ValueError(f"LED start must be non-negative, got {seg.led_start}")
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_total_leds(self) -> int:
|
def get_total_leds(self) -> int:
|
||||||
"""Get total number of LEDs across all segments."""
|
"""Get total number of LEDs across all edges."""
|
||||||
return sum(seg.led_count for seg in self.segments)
|
return self.leds_top + self.leds_right + self.leds_bottom + self.leds_left
|
||||||
|
|
||||||
def get_segment_for_edge(self, edge: str) -> CalibrationSegment | None:
|
def get_segment_for_edge(self, edge: str) -> CalibrationSegment | None:
|
||||||
"""Get segment configuration for a specific edge."""
|
"""Get segment configuration for a specific edge."""
|
||||||
@@ -248,37 +291,13 @@ def create_default_calibration(led_count: int) -> CalibrationConfig:
|
|||||||
top_count = leds_per_edge + (1 if remainder > 1 else 0)
|
top_count = leds_per_edge + (1 if remainder > 1 else 0)
|
||||||
left_count = leds_per_edge + (1 if remainder > 2 else 0)
|
left_count = leds_per_edge + (1 if remainder > 2 else 0)
|
||||||
|
|
||||||
segments = [
|
|
||||||
CalibrationSegment(
|
|
||||||
edge="bottom",
|
|
||||||
led_start=0,
|
|
||||||
led_count=bottom_count,
|
|
||||||
reverse=False,
|
|
||||||
),
|
|
||||||
CalibrationSegment(
|
|
||||||
edge="right",
|
|
||||||
led_start=bottom_count,
|
|
||||||
led_count=right_count,
|
|
||||||
reverse=False,
|
|
||||||
),
|
|
||||||
CalibrationSegment(
|
|
||||||
edge="top",
|
|
||||||
led_start=bottom_count + right_count,
|
|
||||||
led_count=top_count,
|
|
||||||
reverse=True,
|
|
||||||
),
|
|
||||||
CalibrationSegment(
|
|
||||||
edge="left",
|
|
||||||
led_start=bottom_count + right_count + top_count,
|
|
||||||
led_count=left_count,
|
|
||||||
reverse=True,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
config = CalibrationConfig(
|
config = CalibrationConfig(
|
||||||
layout="clockwise",
|
layout="clockwise",
|
||||||
start_position="bottom_left",
|
start_position="bottom_left",
|
||||||
segments=segments,
|
leds_bottom=bottom_count,
|
||||||
|
leds_right=right_count,
|
||||||
|
leds_top=top_count,
|
||||||
|
leds_left=left_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -293,6 +312,9 @@ def create_default_calibration(led_count: int) -> CalibrationConfig:
|
|||||||
def calibration_from_dict(data: dict) -> CalibrationConfig:
|
def calibration_from_dict(data: dict) -> CalibrationConfig:
|
||||||
"""Create calibration configuration from dictionary.
|
"""Create calibration configuration from dictionary.
|
||||||
|
|
||||||
|
Supports both new format (leds_top/right/bottom/left) and legacy format
|
||||||
|
(segments list) for backward compatibility.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: Dictionary with calibration data
|
data: Dictionary with calibration data
|
||||||
|
|
||||||
@@ -303,21 +325,14 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
|
|||||||
ValueError: If data is invalid
|
ValueError: If data is invalid
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
segments = [
|
|
||||||
CalibrationSegment(
|
|
||||||
edge=seg["edge"],
|
|
||||||
led_start=seg["led_start"],
|
|
||||||
led_count=seg["led_count"],
|
|
||||||
reverse=seg.get("reverse", False),
|
|
||||||
)
|
|
||||||
for seg in data["segments"]
|
|
||||||
]
|
|
||||||
|
|
||||||
config = CalibrationConfig(
|
config = CalibrationConfig(
|
||||||
layout=data["layout"],
|
layout=data["layout"],
|
||||||
start_position=data["start_position"],
|
start_position=data["start_position"],
|
||||||
segments=segments,
|
|
||||||
offset=data.get("offset", 0),
|
offset=data.get("offset", 0),
|
||||||
|
leds_top=data.get("leds_top", 0),
|
||||||
|
leds_right=data.get("leds_right", 0),
|
||||||
|
leds_bottom=data.get("leds_bottom", 0),
|
||||||
|
leds_left=data.get("leds_left", 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
config.validate()
|
config.validate()
|
||||||
@@ -326,6 +341,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
|
|||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
raise ValueError(f"Missing required calibration field: {e}")
|
raise ValueError(f"Missing required calibration field: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if isinstance(e, ValueError):
|
||||||
|
raise
|
||||||
raise ValueError(f"Invalid calibration data: {e}")
|
raise ValueError(f"Invalid calibration data: {e}")
|
||||||
|
|
||||||
|
|
||||||
@@ -342,13 +359,8 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
|
|||||||
"layout": config.layout,
|
"layout": config.layout,
|
||||||
"start_position": config.start_position,
|
"start_position": config.start_position,
|
||||||
"offset": config.offset,
|
"offset": config.offset,
|
||||||
"segments": [
|
"leds_top": config.leds_top,
|
||||||
{
|
"leds_right": config.leds_right,
|
||||||
"edge": seg.edge,
|
"leds_bottom": config.leds_bottom,
|
||||||
"led_start": seg.led_start,
|
"leds_left": config.leds_left,
|
||||||
"led_count": seg.led_count,
|
|
||||||
"reverse": seg.reverse,
|
|
||||||
}
|
|
||||||
for seg in config.segments
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -562,6 +562,9 @@ function createDeviceCard(device) {
|
|||||||
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
|
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
|
||||||
📐
|
📐
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="showPixelPreview('${device.id}')" title="${t('preview.button')}">
|
||||||
|
👁️
|
||||||
|
</button>
|
||||||
<button class="btn btn-icon btn-danger" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">
|
<button class="btn btn-icon btn-danger" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">
|
||||||
🗑️
|
🗑️
|
||||||
</button>
|
</button>
|
||||||
@@ -663,34 +666,55 @@ async function removeDevice(deviceId) {
|
|||||||
|
|
||||||
async function showSettings(deviceId) {
|
async function showSettings(deviceId) {
|
||||||
try {
|
try {
|
||||||
// Fetch current device data
|
// Fetch device data and displays in parallel
|
||||||
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
const [deviceResponse, displaysResponse] = await Promise.all([
|
||||||
headers: getHeaders()
|
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
|
||||||
});
|
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
|
||||||
|
]);
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (deviceResponse.status === 401) {
|
||||||
handle401Error();
|
handle401Error();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!deviceResponse.ok) {
|
||||||
showToast('Failed to load device settings', 'error');
|
showToast('Failed to load device settings', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const device = await response.json();
|
const device = await deviceResponse.json();
|
||||||
|
|
||||||
// Populate modal
|
// Populate display index select
|
||||||
|
const displaySelect = document.getElementById('settings-display-index');
|
||||||
|
displaySelect.innerHTML = '';
|
||||||
|
if (displaysResponse.ok) {
|
||||||
|
const displaysData = await displaysResponse.json();
|
||||||
|
(displaysData.displays || []).forEach(d => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = d.index;
|
||||||
|
opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`;
|
||||||
|
displaySelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (displaySelect.options.length === 0) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = '0';
|
||||||
|
opt.textContent = '0';
|
||||||
|
displaySelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
displaySelect.value = String(device.settings.display_index ?? 0);
|
||||||
|
|
||||||
|
// Populate other fields
|
||||||
document.getElementById('settings-device-id').value = device.id;
|
document.getElementById('settings-device-id').value = device.id;
|
||||||
document.getElementById('settings-device-name').value = device.name;
|
document.getElementById('settings-device-name').value = device.name;
|
||||||
document.getElementById('settings-device-url').value = device.url;
|
document.getElementById('settings-device-url').value = device.url;
|
||||||
// Set health check interval
|
|
||||||
document.getElementById('settings-health-interval').value = device.settings.state_check_interval || 30;
|
document.getElementById('settings-health-interval').value = device.settings.state_check_interval || 30;
|
||||||
|
|
||||||
// Snapshot initial values for dirty checking
|
// Snapshot initial values for dirty checking
|
||||||
settingsInitialValues = {
|
settingsInitialValues = {
|
||||||
name: device.name,
|
name: device.name,
|
||||||
url: device.url,
|
url: device.url,
|
||||||
|
display_index: String(device.settings.display_index ?? 0),
|
||||||
state_check_interval: String(device.settings.state_check_interval || 30),
|
state_check_interval: String(device.settings.state_check_interval || 30),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -714,6 +738,7 @@ function isSettingsDirty() {
|
|||||||
return (
|
return (
|
||||||
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
|
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
|
||||||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
|
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
|
||||||
|
document.getElementById('settings-display-index').value !== settingsInitialValues.display_index ||
|
||||||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
|
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -739,6 +764,7 @@ async function saveDeviceSettings() {
|
|||||||
const deviceId = document.getElementById('settings-device-id').value;
|
const deviceId = document.getElementById('settings-device-id').value;
|
||||||
const name = document.getElementById('settings-device-name').value.trim();
|
const name = document.getElementById('settings-device-name').value.trim();
|
||||||
const url = document.getElementById('settings-device-url').value.trim();
|
const url = document.getElementById('settings-device-url').value.trim();
|
||||||
|
const display_index = parseInt(document.getElementById('settings-display-index').value) || 0;
|
||||||
const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
|
const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
|
||||||
const error = document.getElementById('settings-error');
|
const error = document.getElementById('settings-error');
|
||||||
|
|
||||||
@@ -773,7 +799,7 @@ async function saveDeviceSettings() {
|
|||||||
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
body: JSON.stringify({ state_check_interval })
|
body: JSON.stringify({ display_index, state_check_interval })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (settingsResponse.status === 401) {
|
if (settingsResponse.status === 401) {
|
||||||
@@ -817,14 +843,36 @@ async function saveCardBrightness(deviceId, value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add device form handler
|
// Add device modal
|
||||||
|
function showAddDevice() {
|
||||||
|
const modal = document.getElementById('add-device-modal');
|
||||||
|
const form = document.getElementById('add-device-form');
|
||||||
|
const error = document.getElementById('add-device-error');
|
||||||
|
form.reset();
|
||||||
|
error.style.display = 'none';
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
lockBody();
|
||||||
|
setTimeout(() => document.getElementById('device-name').focus(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddDeviceModal() {
|
||||||
|
const modal = document.getElementById('add-device-modal');
|
||||||
|
modal.style.display = 'none';
|
||||||
|
unlockBody();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAddDevice(event) {
|
async function handleAddDevice(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const name = document.getElementById('device-name').value;
|
const name = document.getElementById('device-name').value.trim();
|
||||||
const url = document.getElementById('device-url').value;
|
const url = document.getElementById('device-url').value.trim();
|
||||||
|
const error = document.getElementById('add-device-error');
|
||||||
|
|
||||||
console.log(`Adding device: ${name} (${url})`);
|
if (!name || !url) {
|
||||||
|
error.textContent = 'Please fill in all fields';
|
||||||
|
error.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/devices`, {
|
const response = await fetch(`${API_BASE}/devices`, {
|
||||||
@@ -842,15 +890,16 @@ async function handleAddDevice(event) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('Device added successfully:', result);
|
console.log('Device added successfully:', result);
|
||||||
showToast('Device added successfully', 'success');
|
showToast('Device added successfully', 'success');
|
||||||
event.target.reset();
|
closeAddDeviceModal();
|
||||||
loadDevices();
|
loadDevices();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const errorData = await response.json();
|
||||||
console.error('Failed to add device:', error);
|
console.error('Failed to add device:', errorData);
|
||||||
showToast(`Failed to add device: ${error.detail}`, 'error');
|
error.textContent = `Failed to add device: ${errorData.detail}`;
|
||||||
|
error.style.display = 'block';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Failed to add device:', error);
|
console.error('Failed to add device:', err);
|
||||||
showToast('Failed to add device', 'error');
|
showToast('Failed to add device', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -945,25 +994,20 @@ async function showCalibration(deviceId) {
|
|||||||
document.getElementById('cal-offset').value = calibration.offset || 0;
|
document.getElementById('cal-offset').value = calibration.offset || 0;
|
||||||
|
|
||||||
// Set LED counts per edge
|
// Set LED counts per edge
|
||||||
const edgeCounts = { top: 0, right: 0, bottom: 0, left: 0 };
|
document.getElementById('cal-top-leds').value = calibration.leds_top || 0;
|
||||||
calibration.segments.forEach(seg => {
|
document.getElementById('cal-right-leds').value = calibration.leds_right || 0;
|
||||||
edgeCounts[seg.edge] = seg.led_count;
|
document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0;
|
||||||
});
|
document.getElementById('cal-left-leds').value = calibration.leds_left || 0;
|
||||||
|
|
||||||
document.getElementById('cal-top-leds').value = edgeCounts.top;
|
|
||||||
document.getElementById('cal-right-leds').value = edgeCounts.right;
|
|
||||||
document.getElementById('cal-bottom-leds').value = edgeCounts.bottom;
|
|
||||||
document.getElementById('cal-left-leds').value = edgeCounts.left;
|
|
||||||
|
|
||||||
// Snapshot initial values for dirty checking
|
// Snapshot initial values for dirty checking
|
||||||
calibrationInitialValues = {
|
calibrationInitialValues = {
|
||||||
start_position: calibration.start_position,
|
start_position: calibration.start_position,
|
||||||
layout: calibration.layout,
|
layout: calibration.layout,
|
||||||
offset: String(calibration.offset || 0),
|
offset: String(calibration.offset || 0),
|
||||||
top: String(edgeCounts.top),
|
top: String(calibration.leds_top || 0),
|
||||||
right: String(edgeCounts.right),
|
right: String(calibration.leds_right || 0),
|
||||||
bottom: String(edgeCounts.bottom),
|
bottom: String(calibration.leds_bottom || 0),
|
||||||
left: String(edgeCounts.left),
|
left: String(calibration.leds_left || 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize test mode state for this device
|
// Initialize test mode state for this device
|
||||||
@@ -1167,40 +1211,16 @@ async function saveCalibration() {
|
|||||||
// Build calibration config
|
// Build calibration config
|
||||||
const startPosition = document.getElementById('cal-start-position').value;
|
const startPosition = document.getElementById('cal-start-position').value;
|
||||||
const layout = document.getElementById('cal-layout').value;
|
const layout = document.getElementById('cal-layout').value;
|
||||||
|
|
||||||
// Build segments based on start position and direction
|
|
||||||
const segments = [];
|
|
||||||
let ledStart = 0;
|
|
||||||
|
|
||||||
const edgeOrder = getEdgeOrder(startPosition, layout);
|
|
||||||
|
|
||||||
const edgeCounts = {
|
|
||||||
top: topLeds,
|
|
||||||
right: rightLeds,
|
|
||||||
bottom: bottomLeds,
|
|
||||||
left: leftLeds
|
|
||||||
};
|
|
||||||
|
|
||||||
edgeOrder.forEach(edge => {
|
|
||||||
const count = edgeCounts[edge];
|
|
||||||
if (count > 0) {
|
|
||||||
segments.push({
|
|
||||||
edge: edge,
|
|
||||||
led_start: ledStart,
|
|
||||||
led_count: count,
|
|
||||||
reverse: shouldReverse(edge, startPosition, layout)
|
|
||||||
});
|
|
||||||
ledStart += count;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const offset = parseInt(document.getElementById('cal-offset').value || 0);
|
const offset = parseInt(document.getElementById('cal-offset').value || 0);
|
||||||
|
|
||||||
const calibration = {
|
const calibration = {
|
||||||
layout: layout,
|
layout: layout,
|
||||||
start_position: startPosition,
|
start_position: startPosition,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
segments: segments
|
leds_top: topLeds,
|
||||||
|
leds_right: rightLeds,
|
||||||
|
leds_bottom: bottomLeds,
|
||||||
|
leds_left: leftLeds
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1232,43 +1252,465 @@ async function saveCalibration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getEdgeOrder(startPosition, layout) {
|
function getEdgeOrder(startPosition, layout) {
|
||||||
const clockwise = ['bottom', 'right', 'top', 'left'];
|
|
||||||
const counterclockwise = ['bottom', 'left', 'top', 'right'];
|
|
||||||
|
|
||||||
const orders = {
|
const orders = {
|
||||||
'bottom_left_clockwise': clockwise,
|
'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'],
|
||||||
'bottom_left_counterclockwise': counterclockwise,
|
'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'],
|
||||||
'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'],
|
'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'],
|
||||||
'bottom_right_counterclockwise': ['bottom', 'right', 'top', 'left'],
|
'bottom_right_counterclockwise': ['right', 'top', 'left', 'bottom'],
|
||||||
'top_left_clockwise': ['top', 'right', 'bottom', 'left'],
|
'top_left_clockwise': ['top', 'right', 'bottom', 'left'],
|
||||||
'top_left_counterclockwise': ['top', 'left', 'bottom', 'right'],
|
'top_left_counterclockwise': ['left', 'bottom', 'right', 'top'],
|
||||||
'top_right_clockwise': ['top', 'left', 'bottom', 'right'],
|
'top_right_clockwise': ['right', 'bottom', 'left', 'top'],
|
||||||
'top_right_counterclockwise': ['top', 'right', 'bottom', 'left']
|
'top_right_counterclockwise': ['top', 'left', 'bottom', 'right']
|
||||||
};
|
};
|
||||||
|
|
||||||
return orders[`${startPosition}_${layout}`] || clockwise;
|
return orders[`${startPosition}_${layout}`] || ['left', 'top', 'right', 'bottom'];
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldReverse(edge, startPosition, layout) {
|
function shouldReverse(edge, startPosition, layout) {
|
||||||
// Determine if this edge should be reversed based on LED strip direction
|
// Determine if this edge should be reversed based on LED strip direction
|
||||||
const reverseRules = {
|
const reverseRules = {
|
||||||
'bottom_left_clockwise': { bottom: false, right: false, top: true, left: true },
|
'bottom_left_clockwise': { left: true, top: false, right: false, bottom: true },
|
||||||
'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false },
|
'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false },
|
||||||
'bottom_right_clockwise': { bottom: true, right: false, top: false, left: true },
|
'bottom_right_clockwise': { bottom: true, left: true, top: false, right: false },
|
||||||
'bottom_right_counterclockwise': { bottom: true, right: true, top: false, left: false },
|
'bottom_right_counterclockwise': { right: true, top: true, left: false, bottom: false },
|
||||||
'top_left_clockwise': { top: false, right: false, bottom: true, left: true },
|
'top_left_clockwise': { top: false, right: false, bottom: true, left: true },
|
||||||
'top_left_counterclockwise': { top: false, right: true, bottom: true, left: false },
|
'top_left_counterclockwise': { left: false, bottom: false, right: true, top: true },
|
||||||
'top_right_clockwise': { top: true, right: false, bottom: false, left: true },
|
'top_right_clockwise': { right: false, bottom: true, left: true, top: false },
|
||||||
'top_right_counterclockwise': { top: true, right: true, bottom: false, left: false }
|
'top_right_counterclockwise': { top: true, left: false, bottom: false, right: true }
|
||||||
};
|
};
|
||||||
|
|
||||||
const rules = reverseRules[`${startPosition}_${layout}`];
|
const rules = reverseRules[`${startPosition}_${layout}`];
|
||||||
return rules ? rules[edge] : false;
|
return rules ? rules[edge] : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modals on backdrop click
|
function buildSegments(calibration) {
|
||||||
|
const edgeOrder = getEdgeOrder(calibration.start_position, calibration.layout);
|
||||||
|
const edgeCounts = {
|
||||||
|
top: calibration.leds_top || 0,
|
||||||
|
right: calibration.leds_right || 0,
|
||||||
|
bottom: calibration.leds_bottom || 0,
|
||||||
|
left: calibration.leds_left || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const segments = [];
|
||||||
|
let ledStart = 0;
|
||||||
|
|
||||||
|
edgeOrder.forEach(edge => {
|
||||||
|
const count = edgeCounts[edge];
|
||||||
|
if (count > 0) {
|
||||||
|
segments.push({
|
||||||
|
edge: edge,
|
||||||
|
led_start: ledStart,
|
||||||
|
led_count: count,
|
||||||
|
reverse: shouldReverse(edge, calibration.start_position, calibration.layout)
|
||||||
|
});
|
||||||
|
ledStart += count;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pixel Layout Preview functions
|
||||||
|
|
||||||
|
async function showPixelPreview(deviceId) {
|
||||||
|
try {
|
||||||
|
const [deviceResponse, displaysResponse] = await Promise.all([
|
||||||
|
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
|
||||||
|
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (deviceResponse.status === 401) {
|
||||||
|
handle401Error();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deviceResponse.ok) {
|
||||||
|
showToast('Failed to load device data', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = await deviceResponse.json();
|
||||||
|
const calibration = device.calibration;
|
||||||
|
|
||||||
|
const totalLeds = (calibration?.leds_top || 0) + (calibration?.leds_right || 0) +
|
||||||
|
(calibration?.leds_bottom || 0) + (calibration?.leds_left || 0);
|
||||||
|
if (!calibration || totalLeds === 0) {
|
||||||
|
showToast(t('preview.no_calibration'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive segments from core parameters
|
||||||
|
calibration.segments = buildSegments(calibration);
|
||||||
|
|
||||||
|
let displayWidth = 1920;
|
||||||
|
let displayHeight = 1080;
|
||||||
|
if (displaysResponse.ok) {
|
||||||
|
const displaysData = await displaysResponse.json();
|
||||||
|
const displayIndex = device.settings.display_index || 0;
|
||||||
|
const display = (displaysData.displays || []).find(d => d.index === displayIndex);
|
||||||
|
if (display) {
|
||||||
|
displayWidth = display.width;
|
||||||
|
displayHeight = display.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = document.getElementById('pixel-preview-overlay');
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
lockBody();
|
||||||
|
|
||||||
|
document.getElementById('pixel-preview-device-name').textContent =
|
||||||
|
`${device.name} (${device.led_count} LEDs)`;
|
||||||
|
|
||||||
|
buildPreviewLegend(calibration);
|
||||||
|
|
||||||
|
// Render after layout settles
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
renderPixelPreview(calibration, displayWidth, displayHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay._resizeHandler = () => {
|
||||||
|
renderPixelPreview(calibration, displayWidth, displayHeight);
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', overlay._resizeHandler);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to show pixel preview:', error);
|
||||||
|
showToast('Failed to load pixel preview', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePixelPreview() {
|
||||||
|
const overlay = document.getElementById('pixel-preview-overlay');
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
unlockBody();
|
||||||
|
|
||||||
|
if (overlay._resizeHandler) {
|
||||||
|
window.removeEventListener('resize', overlay._resizeHandler);
|
||||||
|
overlay._resizeHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreviewLegend(calibration) {
|
||||||
|
const legendContainer = document.getElementById('pixel-preview-legend');
|
||||||
|
const edgeNames = {
|
||||||
|
top: t('preview.edge.top'),
|
||||||
|
right: t('preview.edge.right'),
|
||||||
|
bottom: t('preview.edge.bottom'),
|
||||||
|
left: t('preview.edge.left')
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = calibration.segments.map(seg => {
|
||||||
|
const [r, g, b] = EDGE_TEST_COLORS[seg.edge] || [128, 128, 128];
|
||||||
|
const last = seg.led_start + seg.led_count - 1;
|
||||||
|
return `<div class="pixel-preview-legend-item">
|
||||||
|
<div class="pixel-preview-legend-swatch" style="background: rgb(${r},${g},${b})"></div>
|
||||||
|
${edgeNames[seg.edge] || seg.edge}: ${seg.led_count} LEDs (#${seg.led_start}\u2013${last})
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dirText = calibration.layout === 'clockwise' ? t('preview.direction.cw') : t('preview.direction.ccw');
|
||||||
|
items.push(`<div class="pixel-preview-legend-item">
|
||||||
|
${calibration.layout === 'clockwise' ? '\u21BB' : '\u21BA'} ${dirText}
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
if (calibration.offset > 0) {
|
||||||
|
items.push(`<div class="pixel-preview-legend-item">
|
||||||
|
\u2194 ${t('preview.offset_leds', { count: calibration.offset })}
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
legendContainer.innerHTML = items.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPixelPreview(calibration, displayWidth, displayHeight) {
|
||||||
|
const canvas = document.getElementById('pixel-preview-canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const W = rect.width;
|
||||||
|
const H = rect.height;
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
ctx.fillStyle = '#111111';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Calculate screen rectangle with proper aspect ratio
|
||||||
|
const padding = 80;
|
||||||
|
const maxScreenW = W - padding * 2;
|
||||||
|
const maxScreenH = H - padding * 2;
|
||||||
|
if (maxScreenW <= 0 || maxScreenH <= 0) return;
|
||||||
|
|
||||||
|
const displayAspect = displayWidth / displayHeight;
|
||||||
|
let screenW, screenH;
|
||||||
|
|
||||||
|
if (maxScreenW / maxScreenH > displayAspect) {
|
||||||
|
screenH = maxScreenH;
|
||||||
|
screenW = screenH * displayAspect;
|
||||||
|
} else {
|
||||||
|
screenW = maxScreenW;
|
||||||
|
screenH = screenW / displayAspect;
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenX = (W - screenW) / 2;
|
||||||
|
const screenY = (H - screenH) / 2;
|
||||||
|
|
||||||
|
// Draw screen rectangle
|
||||||
|
ctx.fillStyle = '#1a1a2e';
|
||||||
|
ctx.fillRect(screenX, screenY, screenW, screenH);
|
||||||
|
ctx.strokeStyle = '#444';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(screenX, screenY, screenW, screenH);
|
||||||
|
|
||||||
|
// Screen label
|
||||||
|
ctx.fillStyle = '#555';
|
||||||
|
ctx.font = `${Math.min(24, screenH * 0.06)}px -apple-system, BlinkMacSystemFont, sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(`${displayWidth}\u00D7${displayHeight}`, screenX + screenW / 2, screenY + screenH / 2);
|
||||||
|
|
||||||
|
// LED rendering config
|
||||||
|
const maxEdgeLeds = Math.max(...calibration.segments.map(s => s.led_count), 1);
|
||||||
|
const ledMarkerSize = Math.max(2, Math.min(8, 500 / maxEdgeLeds));
|
||||||
|
const stripOffset = ledMarkerSize + 10;
|
||||||
|
|
||||||
|
// Edge geometry
|
||||||
|
const edgeGeometry = {
|
||||||
|
top: { x1: screenX, y1: screenY, x2: screenX + screenW, y2: screenY, horizontal: true, outside: -1 },
|
||||||
|
bottom: { x1: screenX, y1: screenY + screenH, x2: screenX + screenW, y2: screenY + screenH, horizontal: true, outside: 1 },
|
||||||
|
left: { x1: screenX, y1: screenY, x2: screenX, y2: screenY + screenH, horizontal: false, outside: -1 },
|
||||||
|
right: { x1: screenX + screenW, y1: screenY, x2: screenX + screenW, y2: screenY + screenH, horizontal: false, outside: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draw each segment's LEDs
|
||||||
|
calibration.segments.forEach(seg => {
|
||||||
|
const [r, g, b] = EDGE_TEST_COLORS[seg.edge] || [128, 128, 128];
|
||||||
|
const geo = edgeGeometry[seg.edge];
|
||||||
|
if (!geo) return;
|
||||||
|
|
||||||
|
const positions = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < seg.led_count; i++) {
|
||||||
|
const fraction = seg.led_count > 1 ? i / (seg.led_count - 1) : 0.5;
|
||||||
|
const displayFraction = seg.reverse ? (1 - fraction) : fraction;
|
||||||
|
|
||||||
|
let cx, cy;
|
||||||
|
if (geo.horizontal) {
|
||||||
|
cx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
|
||||||
|
cy = geo.y1 + geo.outside * stripOffset;
|
||||||
|
} else {
|
||||||
|
cx = geo.x1 + geo.outside * stripOffset;
|
||||||
|
cy = geo.y1 + displayFraction * (geo.y2 - geo.y1);
|
||||||
|
}
|
||||||
|
|
||||||
|
positions.push({ cx, cy, ledIndex: seg.led_start + i });
|
||||||
|
|
||||||
|
// Draw LED marker
|
||||||
|
ctx.fillStyle = `rgba(${r},${g},${b},0.85)`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, ledMarkerSize / 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw LED index labels
|
||||||
|
drawPreviewLedLabels(ctx, positions, ledMarkerSize, geo);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw start position marker
|
||||||
|
drawPreviewStartPosition(ctx, calibration, screenX, screenY, screenW, screenH);
|
||||||
|
|
||||||
|
// Draw direction arrows
|
||||||
|
drawPreviewDirectionArrows(ctx, calibration, edgeGeometry);
|
||||||
|
|
||||||
|
// Draw offset indicator
|
||||||
|
if (calibration.offset > 0) {
|
||||||
|
drawPreviewOffsetIndicator(ctx, calibration, screenX, screenY, screenW, screenH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPreviewLedLabels(ctx, positions, markerSize, geo) {
|
||||||
|
if (positions.length === 0) return;
|
||||||
|
|
||||||
|
const labelFontSize = Math.max(9, Math.min(12, 200 / Math.sqrt(positions.length)));
|
||||||
|
ctx.font = `${labelFontSize}px -apple-system, BlinkMacSystemFont, sans-serif`;
|
||||||
|
ctx.fillStyle = '#ccc';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
// Adaptive label interval
|
||||||
|
const count = positions.length;
|
||||||
|
const labelInterval = count <= 20 ? 1
|
||||||
|
: count <= 50 ? 5
|
||||||
|
: count <= 100 ? 10
|
||||||
|
: count <= 200 ? 25
|
||||||
|
: 50;
|
||||||
|
|
||||||
|
const labelsToShow = new Set();
|
||||||
|
labelsToShow.add(0);
|
||||||
|
labelsToShow.add(count - 1);
|
||||||
|
for (let i = labelInterval; i < count - 1; i += labelInterval) {
|
||||||
|
labelsToShow.add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelOffset = markerSize / 2 + labelFontSize;
|
||||||
|
|
||||||
|
labelsToShow.forEach(i => {
|
||||||
|
const pos = positions[i];
|
||||||
|
const label = String(pos.ledIndex);
|
||||||
|
|
||||||
|
if (geo.horizontal) {
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(label, pos.cx, pos.cy + geo.outside * labelOffset);
|
||||||
|
} else {
|
||||||
|
ctx.textAlign = geo.outside < 0 ? 'right' : 'left';
|
||||||
|
ctx.fillText(label, pos.cx + geo.outside * labelOffset, pos.cy);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPreviewStartPosition(ctx, calibration, screenX, screenY, screenW, screenH) {
|
||||||
|
const corners = {
|
||||||
|
top_left: { x: screenX, y: screenY },
|
||||||
|
top_right: { x: screenX + screenW, y: screenY },
|
||||||
|
bottom_left: { x: screenX, y: screenY + screenH },
|
||||||
|
bottom_right: { x: screenX + screenW, y: screenY + screenH },
|
||||||
|
};
|
||||||
|
|
||||||
|
const corner = corners[calibration.start_position];
|
||||||
|
if (!corner) return;
|
||||||
|
|
||||||
|
// Green diamond
|
||||||
|
const size = 10;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(corner.x, corner.y);
|
||||||
|
ctx.rotate(Math.PI / 4);
|
||||||
|
ctx.fillStyle = '#4CAF50';
|
||||||
|
ctx.shadowColor = 'rgba(76, 175, 80, 0.6)';
|
||||||
|
ctx.shadowBlur = 8;
|
||||||
|
ctx.fillRect(-size / 2, -size / 2, size, size);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// START label
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, sans-serif';
|
||||||
|
ctx.fillStyle = '#4CAF50';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
const lx = calibration.start_position.includes('left') ? -28 : 28;
|
||||||
|
const ly = calibration.start_position.includes('top') ? -18 : 18;
|
||||||
|
ctx.fillText(t('preview.start'), corner.x + lx, corner.y + ly);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPreviewDirectionArrows(ctx, calibration, edgeGeometry) {
|
||||||
|
const arrowSize = 8;
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
||||||
|
|
||||||
|
calibration.segments.forEach(seg => {
|
||||||
|
const geo = edgeGeometry[seg.edge];
|
||||||
|
if (!geo) return;
|
||||||
|
|
||||||
|
// Midpoint of edge, shifted outside
|
||||||
|
const midFraction = 0.5;
|
||||||
|
let mx, my;
|
||||||
|
if (geo.horizontal) {
|
||||||
|
mx = geo.x1 + midFraction * (geo.x2 - geo.x1);
|
||||||
|
my = geo.y1 + geo.outside * (arrowSize + 20);
|
||||||
|
} else {
|
||||||
|
mx = geo.x1 + geo.outside * (arrowSize + 20);
|
||||||
|
my = geo.y1 + midFraction * (geo.y2 - geo.y1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direction based on edge and reverse
|
||||||
|
let angle;
|
||||||
|
if (geo.horizontal) {
|
||||||
|
angle = seg.reverse ? Math.PI : 0; // left or right
|
||||||
|
} else {
|
||||||
|
angle = seg.reverse ? -Math.PI / 2 : Math.PI / 2; // up or down
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(mx, my);
|
||||||
|
ctx.rotate(angle);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(arrowSize, 0);
|
||||||
|
ctx.lineTo(-arrowSize / 2, -arrowSize / 2);
|
||||||
|
ctx.lineTo(-arrowSize / 2, arrowSize / 2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPreviewOffsetIndicator(ctx, calibration, screenX, screenY, screenW, screenH) {
|
||||||
|
const corners = {
|
||||||
|
top_left: { x: screenX, y: screenY },
|
||||||
|
top_right: { x: screenX + screenW, y: screenY },
|
||||||
|
bottom_left: { x: screenX, y: screenY + screenH },
|
||||||
|
bottom_right: { x: screenX + screenW, y: screenY + screenH },
|
||||||
|
};
|
||||||
|
|
||||||
|
const corner = corners[calibration.start_position];
|
||||||
|
if (!corner) return;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif';
|
||||||
|
ctx.fillStyle = '#ff9800';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
const ox = calibration.start_position.includes('left') ? -45 : 45;
|
||||||
|
const oy = calibration.start_position.includes('top') ? -35 : 35;
|
||||||
|
ctx.fillText(
|
||||||
|
t('preview.offset_leds', { count: calibration.offset }),
|
||||||
|
corner.x + ox,
|
||||||
|
corner.y + oy
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dashed arc
|
||||||
|
ctx.strokeStyle = '#ff9800';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.setLineDash([3, 3]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(corner.x, corner.y, 18, 0, Math.PI * 0.5);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close pixel preview on canvas click
|
||||||
|
document.getElementById('pixel-preview-canvas').addEventListener('click', () => {
|
||||||
|
closePixelPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close pixel preview on Escape key
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const overlay = document.getElementById('pixel-preview-overlay');
|
||||||
|
if (overlay.style.display !== 'none') {
|
||||||
|
closePixelPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modals on backdrop click (only if mousedown also started on backdrop)
|
||||||
|
let backdropMouseDownTarget = null;
|
||||||
|
document.addEventListener('mousedown', (e) => {
|
||||||
|
backdropMouseDownTarget = e.target;
|
||||||
|
});
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (!e.target.classList.contains('modal')) return;
|
if (!e.target.classList.contains('modal')) return;
|
||||||
|
if (backdropMouseDownTarget !== e.target) return;
|
||||||
|
|
||||||
const modalId = e.target.id;
|
const modalId = e.target.id;
|
||||||
|
|
||||||
@@ -1298,6 +1740,12 @@ document.addEventListener('click', (e) => {
|
|||||||
closeCalibrationModal();
|
closeCalibrationModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add device modal: close on backdrop
|
||||||
|
if (modalId === 'add-device-modal') {
|
||||||
|
closeAddDeviceModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup on page unload
|
// Cleanup on page unload
|
||||||
|
|||||||
@@ -45,32 +45,15 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="devices-section">
|
<section class="devices-section">
|
||||||
<h2 data-i18n="devices.title">WLED Devices</h2>
|
<div class="section-header">
|
||||||
|
<h2 data-i18n="devices.title">WLED Devices</h2>
|
||||||
|
<button class="btn btn-icon btn-primary" onclick="showAddDevice()" data-i18n-title="devices.add" title="Add New Device">+</button>
|
||||||
|
</div>
|
||||||
<div id="devices-list" class="devices-grid">
|
<div id="devices-list" class="devices-grid">
|
||||||
<div class="loading" data-i18n="devices.loading">Loading devices...</div>
|
<div class="loading" data-i18n="devices.loading">Loading devices...</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="add-device-section">
|
|
||||||
<h2 data-i18n="devices.add">Add New Device</h2>
|
|
||||||
<div class="info-banner" style="margin-bottom: 20px; padding: 12px; background: rgba(33, 150, 243, 0.1); border-left: 4px solid #2196F3; border-radius: 4px;">
|
|
||||||
<strong><span data-i18n="devices.wled_config">📱 WLED Configuration:</span></strong> <span data-i18n="devices.wled_note">Configure your WLED device (effects, segments, color order, power limits, etc.) using the</span>
|
|
||||||
<a href="https://kno.wled.ge/" target="_blank" rel="noopener" style="color: #2196F3; text-decoration: underline;" data-i18n="devices.wled_link">official WLED app</a>.
|
|
||||||
<span data-i18n="devices.wled_note2">This controller sends pixel color data and controls brightness per device.</span>
|
|
||||||
</div>
|
|
||||||
<form id="add-device-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="device-name" data-i18n="device.name">Device Name:</label>
|
|
||||||
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="device-url" data-i18n="device.url">WLED URL:</label>
|
|
||||||
<input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary" data-i18n="device.button.add">Add Device</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<div class="footer-content">
|
<div class="footer-content">
|
||||||
<p>
|
<p>
|
||||||
@@ -192,6 +175,11 @@
|
|||||||
<small class="input-hint" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
|
<small class="input-hint" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="settings-display-index" data-i18n="settings.display_index">Display:</label>
|
||||||
|
<select id="settings-display-index"></select>
|
||||||
|
<small class="input-hint" data-i18n="settings.display_index.hint">Which screen to capture for this device</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
|
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
|
||||||
@@ -262,6 +250,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Device Modal -->
|
||||||
|
<div id="add-device-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 data-i18n="devices.add">Add New Device</h2>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="info-banner" style="margin-bottom: 16px; padding: 12px; background: rgba(33, 150, 243, 0.1); border-left: 4px solid #2196F3; border-radius: 4px;">
|
||||||
|
<strong><span data-i18n="devices.wled_config">WLED Configuration:</span></strong> <span data-i18n="devices.wled_note">Configure your WLED device (effects, segments, color order, power limits, etc.) using the</span>
|
||||||
|
<a href="https://kno.wled.ge/" target="_blank" rel="noopener" style="color: #2196F3; text-decoration: underline;" data-i18n="devices.wled_link">official WLED app</a>.
|
||||||
|
<span data-i18n="devices.wled_note2">This controller sends pixel color data and controls brightness per device.</span>
|
||||||
|
</div>
|
||||||
|
<form id="add-device-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="device-name" data-i18n="device.name">Device Name:</label>
|
||||||
|
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="device-url" data-i18n="device.url">WLED URL:</label>
|
||||||
|
<input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
||||||
|
</div>
|
||||||
|
<div id="add-device-error" class="error-message" style="display: none;"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeAddDeviceModal()" data-i18n="calibration.button.cancel">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="document.getElementById('add-device-form').requestSubmit()" data-i18n="device.button.add">Add Device</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pixel Layout Preview Overlay -->
|
||||||
|
<div id="pixel-preview-overlay" class="pixel-preview-overlay" style="display: none;">
|
||||||
|
<div class="pixel-preview-header">
|
||||||
|
<span class="pixel-preview-title" data-i18n="preview.title">Pixel Layout Preview</span>
|
||||||
|
<span id="pixel-preview-device-name" class="pixel-preview-device-name"></span>
|
||||||
|
<button class="pixel-preview-close" onclick="closePixelPreview()" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<canvas id="pixel-preview-canvas"></canvas>
|
||||||
|
<div class="pixel-preview-legend" id="pixel-preview-legend"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Initialize theme
|
// Initialize theme
|
||||||
|
|||||||
@@ -77,6 +77,8 @@
|
|||||||
"settings.brightness": "Brightness:",
|
"settings.brightness": "Brightness:",
|
||||||
"settings.brightness.hint": "Global brightness for this WLED device (0-100%)",
|
"settings.brightness.hint": "Global brightness for this WLED device (0-100%)",
|
||||||
"settings.url.hint": "IP address or hostname of your WLED device",
|
"settings.url.hint": "IP address or hostname of your WLED device",
|
||||||
|
"settings.display_index": "Display:",
|
||||||
|
"settings.display_index.hint": "Which screen to capture for this device",
|
||||||
"settings.button.cancel": "Cancel",
|
"settings.button.cancel": "Cancel",
|
||||||
"settings.health_interval": "Health Check Interval (s):",
|
"settings.health_interval": "Health Check Interval (s):",
|
||||||
"settings.health_interval.hint": "How often to check the WLED device status (5-600 seconds)",
|
"settings.health_interval.hint": "How often to check the WLED device status (5-600 seconds)",
|
||||||
@@ -106,6 +108,17 @@
|
|||||||
"calibration.button.save": "Save",
|
"calibration.button.save": "Save",
|
||||||
"calibration.saved": "Calibration saved successfully",
|
"calibration.saved": "Calibration saved successfully",
|
||||||
"calibration.failed": "Failed to save calibration",
|
"calibration.failed": "Failed to save calibration",
|
||||||
|
"preview.title": "Pixel Layout Preview",
|
||||||
|
"preview.button": "Preview",
|
||||||
|
"preview.start": "START",
|
||||||
|
"preview.offset_leds": "Offset: {count} LEDs",
|
||||||
|
"preview.direction.cw": "Clockwise",
|
||||||
|
"preview.direction.ccw": "Counterclockwise",
|
||||||
|
"preview.edge.top": "Top",
|
||||||
|
"preview.edge.right": "Right",
|
||||||
|
"preview.edge.bottom": "Bottom",
|
||||||
|
"preview.edge.left": "Left",
|
||||||
|
"preview.no_calibration": "No calibration data. Please calibrate the device first.",
|
||||||
"server.healthy": "Server online",
|
"server.healthy": "Server online",
|
||||||
"server.offline": "Server offline",
|
"server.offline": "Server offline",
|
||||||
"error.unauthorized": "Unauthorized - please login",
|
"error.unauthorized": "Unauthorized - please login",
|
||||||
|
|||||||
@@ -77,6 +77,8 @@
|
|||||||
"settings.brightness": "Яркость:",
|
"settings.brightness": "Яркость:",
|
||||||
"settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)",
|
"settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)",
|
||||||
"settings.url.hint": "IP адрес или имя хоста вашего WLED устройства",
|
"settings.url.hint": "IP адрес или имя хоста вашего WLED устройства",
|
||||||
|
"settings.display_index": "Дисплей:",
|
||||||
|
"settings.display_index.hint": "Какой экран захватывать для этого устройства",
|
||||||
"settings.button.cancel": "Отмена",
|
"settings.button.cancel": "Отмена",
|
||||||
"settings.health_interval": "Интервал Проверки (с):",
|
"settings.health_interval": "Интервал Проверки (с):",
|
||||||
"settings.health_interval.hint": "Как часто проверять статус WLED устройства (5-600 секунд)",
|
"settings.health_interval.hint": "Как часто проверять статус WLED устройства (5-600 секунд)",
|
||||||
@@ -106,6 +108,17 @@
|
|||||||
"calibration.button.save": "Сохранить",
|
"calibration.button.save": "Сохранить",
|
||||||
"calibration.saved": "Калибровка успешно сохранена",
|
"calibration.saved": "Калибровка успешно сохранена",
|
||||||
"calibration.failed": "Не удалось сохранить калибровку",
|
"calibration.failed": "Не удалось сохранить калибровку",
|
||||||
|
"preview.title": "Предпросмотр Расположения Пикселей",
|
||||||
|
"preview.button": "Предпросмотр",
|
||||||
|
"preview.start": "СТАРТ",
|
||||||
|
"preview.offset_leds": "Смещение: {count} LED",
|
||||||
|
"preview.direction.cw": "По часовой",
|
||||||
|
"preview.direction.ccw": "Против часовой",
|
||||||
|
"preview.edge.top": "Верх",
|
||||||
|
"preview.edge.right": "Право",
|
||||||
|
"preview.edge.bottom": "Низ",
|
||||||
|
"preview.edge.left": "Лево",
|
||||||
|
"preview.no_calibration": "Нет данных калибровки. Сначала откалибруйте устройство.",
|
||||||
"server.healthy": "Сервер онлайн",
|
"server.healthy": "Сервер онлайн",
|
||||||
"server.offline": "Сервер офлайн",
|
"server.offline": "Сервер офлайн",
|
||||||
"error.unauthorized": "Не авторизован - пожалуйста, войдите",
|
"error.unauthorized": "Не авторизован - пожалуйста, войдите",
|
||||||
|
|||||||
@@ -164,6 +164,10 @@ section {
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.devices-grid > .loading {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -487,11 +491,15 @@ section {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-device-section {
|
.section-header {
|
||||||
background: var(--card-bg);
|
display: flex;
|
||||||
border: 1px solid var(--border-color);
|
align-items: center;
|
||||||
border-radius: 8px;
|
gap: 10px;
|
||||||
padding: 20px;
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
@@ -981,3 +989,92 @@ input:-webkit-autofill:focus {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pixel Layout Preview Overlay */
|
||||||
|
.pixel-preview-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #111111;
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pixel-preview-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pixel-preview-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pixel-preview-device-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #999;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pixel-preview-close {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pixel-preview-close:hover {
|
||||||
|
background: rgba(244, 67, 54, 0.3);
|
||||||
|
border-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pixel-preview-canvas {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pixel-preview-legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pixel-preview-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pixel-preview-legend-swatch {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from wled_controller.core.calibration import (
|
|||||||
create_default_calibration,
|
create_default_calibration,
|
||||||
calibration_from_dict,
|
calibration_from_dict,
|
||||||
calibration_to_dict,
|
calibration_to_dict,
|
||||||
|
EDGE_ORDER,
|
||||||
|
EDGE_REVERSE,
|
||||||
)
|
)
|
||||||
from wled_controller.core.screen_capture import BorderPixels
|
from wled_controller.core.screen_capture import BorderPixels
|
||||||
|
|
||||||
@@ -31,84 +33,50 @@ def test_calibration_segment():
|
|||||||
|
|
||||||
def test_calibration_config_validation():
|
def test_calibration_config_validation():
|
||||||
"""Test calibration configuration validation."""
|
"""Test calibration configuration validation."""
|
||||||
segments = [
|
|
||||||
CalibrationSegment(edge="bottom", led_start=0, led_count=40),
|
|
||||||
CalibrationSegment(edge="right", led_start=40, led_count=30),
|
|
||||||
CalibrationSegment(edge="top", led_start=70, led_count=40),
|
|
||||||
CalibrationSegment(edge="left", led_start=110, led_count=40),
|
|
||||||
]
|
|
||||||
|
|
||||||
config = CalibrationConfig(
|
config = CalibrationConfig(
|
||||||
layout="clockwise",
|
layout="clockwise",
|
||||||
start_position="bottom_left",
|
start_position="bottom_left",
|
||||||
segments=segments,
|
leds_bottom=40,
|
||||||
|
leds_right=30,
|
||||||
|
leds_top=40,
|
||||||
|
leds_left=40,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert config.validate() is True
|
assert config.validate() is True
|
||||||
assert config.get_total_leds() == 150
|
assert config.get_total_leds() == 150
|
||||||
|
|
||||||
|
|
||||||
def test_calibration_config_duplicate_edges():
|
def test_calibration_config_all_zero_leds():
|
||||||
"""Test validation fails with duplicate edges."""
|
"""Test validation fails when all LED counts are zero."""
|
||||||
segments = [
|
|
||||||
CalibrationSegment(edge="top", led_start=0, led_count=40),
|
|
||||||
CalibrationSegment(edge="top", led_start=40, led_count=40), # Duplicate
|
|
||||||
]
|
|
||||||
|
|
||||||
config = CalibrationConfig(
|
config = CalibrationConfig(
|
||||||
layout="clockwise",
|
layout="clockwise",
|
||||||
start_position="bottom_left",
|
start_position="bottom_left",
|
||||||
segments=segments,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Duplicate edges"):
|
with pytest.raises(ValueError, match="at least one LED"):
|
||||||
config.validate()
|
config.validate()
|
||||||
|
|
||||||
|
|
||||||
def test_calibration_config_overlapping_indices():
|
def test_calibration_config_negative_led_count():
|
||||||
"""Test validation fails with overlapping LED indices."""
|
"""Test validation fails with negative LED counts."""
|
||||||
segments = [
|
|
||||||
CalibrationSegment(edge="bottom", led_start=0, led_count=50),
|
|
||||||
CalibrationSegment(edge="right", led_start=40, led_count=30), # Overlaps
|
|
||||||
]
|
|
||||||
|
|
||||||
config = CalibrationConfig(
|
config = CalibrationConfig(
|
||||||
layout="clockwise",
|
layout="clockwise",
|
||||||
start_position="bottom_left",
|
start_position="bottom_left",
|
||||||
segments=segments,
|
leds_top=-5,
|
||||||
|
leds_bottom=40,
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="overlap"):
|
with pytest.raises(ValueError, match="non-negative"):
|
||||||
config.validate()
|
|
||||||
|
|
||||||
|
|
||||||
def test_calibration_config_invalid_led_count():
|
|
||||||
"""Test validation fails with invalid LED counts."""
|
|
||||||
segments = [
|
|
||||||
CalibrationSegment(edge="top", led_start=0, led_count=0), # Invalid
|
|
||||||
]
|
|
||||||
|
|
||||||
config = CalibrationConfig(
|
|
||||||
layout="clockwise",
|
|
||||||
start_position="bottom_left",
|
|
||||||
segments=segments,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
config.validate()
|
config.validate()
|
||||||
|
|
||||||
|
|
||||||
def test_get_segment_for_edge():
|
def test_get_segment_for_edge():
|
||||||
"""Test getting segment by edge name."""
|
"""Test getting segment by edge name."""
|
||||||
segments = [
|
|
||||||
CalibrationSegment(edge="bottom", led_start=0, led_count=40),
|
|
||||||
CalibrationSegment(edge="right", led_start=40, led_count=30),
|
|
||||||
]
|
|
||||||
|
|
||||||
config = CalibrationConfig(
|
config = CalibrationConfig(
|
||||||
layout="clockwise",
|
layout="clockwise",
|
||||||
start_position="bottom_left",
|
start_position="bottom_left",
|
||||||
segments=segments,
|
leds_bottom=40,
|
||||||
|
leds_right=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
bottom_seg = config.get_segment_for_edge("bottom")
|
bottom_seg = config.get_segment_for_edge("bottom")
|
||||||
@@ -119,6 +87,100 @@ def test_get_segment_for_edge():
|
|||||||
assert missing_seg is None
|
assert missing_seg is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_segments_basic():
|
||||||
|
"""Test that build_segments produces correct output."""
|
||||||
|
config = CalibrationConfig(
|
||||||
|
layout="clockwise",
|
||||||
|
start_position="bottom_left",
|
||||||
|
leds_bottom=10,
|
||||||
|
leds_right=20,
|
||||||
|
leds_top=10,
|
||||||
|
leds_left=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
segments = config.build_segments()
|
||||||
|
assert len(segments) == 4
|
||||||
|
|
||||||
|
# Clockwise from bottom_left: left(up), top(right), right(down), bottom(left)
|
||||||
|
assert segments[0].edge == "left"
|
||||||
|
assert segments[0].led_start == 0
|
||||||
|
assert segments[0].led_count == 20
|
||||||
|
assert segments[0].reverse is True
|
||||||
|
|
||||||
|
assert segments[1].edge == "top"
|
||||||
|
assert segments[1].led_start == 20
|
||||||
|
assert segments[1].led_count == 10
|
||||||
|
assert segments[1].reverse is False
|
||||||
|
|
||||||
|
assert segments[2].edge == "right"
|
||||||
|
assert segments[2].led_start == 30
|
||||||
|
assert segments[2].led_count == 20
|
||||||
|
assert segments[2].reverse is False
|
||||||
|
|
||||||
|
assert segments[3].edge == "bottom"
|
||||||
|
assert segments[3].led_start == 50
|
||||||
|
assert segments[3].led_count == 10
|
||||||
|
assert segments[3].reverse is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_segments_skips_zero_edges():
|
||||||
|
"""Test that edges with 0 LEDs are skipped."""
|
||||||
|
config = CalibrationConfig(
|
||||||
|
layout="clockwise",
|
||||||
|
start_position="bottom_left",
|
||||||
|
leds_right=288,
|
||||||
|
leds_top=358,
|
||||||
|
leds_left=288,
|
||||||
|
)
|
||||||
|
|
||||||
|
segments = config.build_segments()
|
||||||
|
assert len(segments) == 3
|
||||||
|
edges = [s.edge for s in segments]
|
||||||
|
assert "bottom" not in edges
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("start_position,layout", [
|
||||||
|
("bottom_left", "clockwise"),
|
||||||
|
("bottom_left", "counterclockwise"),
|
||||||
|
("bottom_right", "clockwise"),
|
||||||
|
("bottom_right", "counterclockwise"),
|
||||||
|
("top_left", "clockwise"),
|
||||||
|
("top_left", "counterclockwise"),
|
||||||
|
("top_right", "clockwise"),
|
||||||
|
("top_right", "counterclockwise"),
|
||||||
|
])
|
||||||
|
def test_build_segments_all_combinations(start_position, layout):
|
||||||
|
"""Test build_segments matches lookup tables for all 8 combinations."""
|
||||||
|
config = CalibrationConfig(
|
||||||
|
layout=layout,
|
||||||
|
start_position=start_position,
|
||||||
|
leds_top=10,
|
||||||
|
leds_right=10,
|
||||||
|
leds_bottom=10,
|
||||||
|
leds_left=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
segments = config.build_segments()
|
||||||
|
assert len(segments) == 4
|
||||||
|
|
||||||
|
# Verify edge order matches EDGE_ORDER table
|
||||||
|
expected_order = EDGE_ORDER[(start_position, layout)]
|
||||||
|
actual_order = [s.edge for s in segments]
|
||||||
|
assert actual_order == expected_order
|
||||||
|
|
||||||
|
# Verify reverse flags match EDGE_REVERSE table
|
||||||
|
expected_reverse = EDGE_REVERSE[(start_position, layout)]
|
||||||
|
for seg in segments:
|
||||||
|
assert seg.reverse == expected_reverse[seg.edge], \
|
||||||
|
f"Mismatch for {start_position}/{layout}/{seg.edge}: expected reverse={expected_reverse[seg.edge]}"
|
||||||
|
|
||||||
|
# Verify led_start values are cumulative
|
||||||
|
expected_start = 0
|
||||||
|
for seg in segments:
|
||||||
|
assert seg.led_start == expected_start
|
||||||
|
expected_start += seg.led_count
|
||||||
|
|
||||||
|
|
||||||
def test_pixel_mapper_initialization():
|
def test_pixel_mapper_initialization():
|
||||||
"""Test pixel mapper initialization."""
|
"""Test pixel mapper initialization."""
|
||||||
config = create_default_calibration(150)
|
config = create_default_calibration(150)
|
||||||
@@ -156,12 +218,13 @@ def test_pixel_mapper_map_border_to_leds():
|
|||||||
|
|
||||||
# Verify colors are reasonable (allowing for some rounding)
|
# Verify colors are reasonable (allowing for some rounding)
|
||||||
# Bottom LEDs should be mostly blue
|
# Bottom LEDs should be mostly blue
|
||||||
bottom_color = led_colors[0]
|
bottom_seg = config.get_segment_for_edge("bottom")
|
||||||
|
bottom_color = led_colors[bottom_seg.led_start]
|
||||||
assert bottom_color[2] > 200 # Blue channel high
|
assert bottom_color[2] > 200 # Blue channel high
|
||||||
|
|
||||||
# Top LEDs should be mostly red
|
# Top LEDs should be mostly red
|
||||||
top_segment = config.get_segment_for_edge("top")
|
top_seg = config.get_segment_for_edge("top")
|
||||||
top_color = led_colors[top_segment.led_start]
|
top_color = led_colors[top_seg.led_start]
|
||||||
assert top_color[0] > 200 # Red channel high
|
assert top_color[0] > 200 # Red channel high
|
||||||
|
|
||||||
|
|
||||||
@@ -190,9 +253,7 @@ def test_pixel_mapper_test_calibration_invalid_edge():
|
|||||||
config = CalibrationConfig(
|
config = CalibrationConfig(
|
||||||
layout="clockwise",
|
layout="clockwise",
|
||||||
start_position="bottom_left",
|
start_position="bottom_left",
|
||||||
segments=[
|
leds_bottom=40,
|
||||||
CalibrationSegment(edge="bottom", led_start=0, led_count=40),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
mapper = PixelMapper(config)
|
mapper = PixelMapper(config)
|
||||||
|
|
||||||
@@ -209,7 +270,13 @@ def test_create_default_calibration():
|
|||||||
assert len(config.segments) == 4
|
assert len(config.segments) == 4
|
||||||
assert config.get_total_leds() == 150
|
assert config.get_total_leds() == 150
|
||||||
|
|
||||||
# Check all edges are present
|
# Check all edges have LEDs
|
||||||
|
assert config.leds_top > 0
|
||||||
|
assert config.leds_right > 0
|
||||||
|
assert config.leds_bottom > 0
|
||||||
|
assert config.leds_left > 0
|
||||||
|
|
||||||
|
# Check all edges are present in derived segments
|
||||||
edges = {seg.edge for seg in config.segments}
|
edges = {seg.edge for seg in config.segments}
|
||||||
assert edges == {"top", "right", "bottom", "left"}
|
assert edges == {"top", "right", "bottom", "left"}
|
||||||
|
|
||||||
@@ -227,22 +294,28 @@ def test_create_default_calibration_invalid():
|
|||||||
|
|
||||||
|
|
||||||
def test_calibration_from_dict():
|
def test_calibration_from_dict():
|
||||||
"""Test creating calibration from dictionary."""
|
"""Test creating calibration from new format dictionary."""
|
||||||
data = {
|
data = {
|
||||||
"layout": "clockwise",
|
"layout": "clockwise",
|
||||||
"start_position": "bottom_left",
|
"start_position": "bottom_left",
|
||||||
"segments": [
|
"offset": 5,
|
||||||
{"edge": "bottom", "led_start": 0, "led_count": 40, "reverse": False},
|
"leds_top": 40,
|
||||||
{"edge": "right", "led_start": 40, "led_count": 30, "reverse": False},
|
"leds_right": 30,
|
||||||
],
|
"leds_bottom": 40,
|
||||||
|
"leds_left": 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
config = calibration_from_dict(data)
|
config = calibration_from_dict(data)
|
||||||
|
|
||||||
assert config.layout == "clockwise"
|
assert config.layout == "clockwise"
|
||||||
assert config.start_position == "bottom_left"
|
assert config.start_position == "bottom_left"
|
||||||
assert len(config.segments) == 2
|
assert config.offset == 5
|
||||||
assert config.get_total_leds() == 70
|
assert config.leds_top == 40
|
||||||
|
assert config.leds_right == 30
|
||||||
|
assert config.leds_bottom == 40
|
||||||
|
assert config.leds_left == 30
|
||||||
|
assert config.get_total_leds() == 140
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_calibration_from_dict_missing_field():
|
def test_calibration_from_dict_missing_field():
|
||||||
@@ -250,7 +323,7 @@ def test_calibration_from_dict_missing_field():
|
|||||||
data = {
|
data = {
|
||||||
"layout": "clockwise",
|
"layout": "clockwise",
|
||||||
# Missing start_position
|
# Missing start_position
|
||||||
"segments": [],
|
"leds_top": 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
@@ -264,9 +337,12 @@ def test_calibration_to_dict():
|
|||||||
|
|
||||||
assert "layout" in data
|
assert "layout" in data
|
||||||
assert "start_position" in data
|
assert "start_position" in data
|
||||||
assert "segments" in data
|
assert "leds_top" in data
|
||||||
assert isinstance(data["segments"], list)
|
assert "leds_right" in data
|
||||||
assert len(data["segments"]) == 4
|
assert "leds_bottom" in data
|
||||||
|
assert "leds_left" in data
|
||||||
|
assert "segments" not in data
|
||||||
|
assert data["leds_top"] + data["leds_right"] + data["leds_bottom"] + data["leds_left"] == 100
|
||||||
|
|
||||||
|
|
||||||
def test_calibration_round_trip():
|
def test_calibration_round_trip():
|
||||||
@@ -277,5 +353,9 @@ def test_calibration_round_trip():
|
|||||||
|
|
||||||
assert restored.layout == original.layout
|
assert restored.layout == original.layout
|
||||||
assert restored.start_position == original.start_position
|
assert restored.start_position == original.start_position
|
||||||
assert len(restored.segments) == len(original.segments)
|
assert restored.leds_top == original.leds_top
|
||||||
|
assert restored.leds_right == original.leds_right
|
||||||
|
assert restored.leds_bottom == original.leds_bottom
|
||||||
|
assert restored.leds_left == original.leds_left
|
||||||
assert restored.get_total_leds() == original.get_total_leds()
|
assert restored.get_total_leds() == original.get_total_leds()
|
||||||
|
assert len(restored.segments) == len(original.segments)
|
||||||
|
|||||||
Reference in New Issue
Block a user