Compare commits

...

4 Commits

Author SHA1 Message Date
5370d80466 Add capture template system with in-memory defaults and split device settings UI
Some checks failed
Validate / validate (push) Failing after 8s
- Generate default templates (MSS, DXcam, WGC) in memory from EngineRegistry at startup
- Only persist user-created templates to JSON, skip defaults on load/save
- Add capture_template_id to Device model and DeviceCreate schema
- Remember last used template in localStorage, use it for new devices with fallback
- Split Device Settings dialog into General Settings and Capture Settings
- Add capture settings button (🎬) to device card
- Separate default and custom templates with visual separator in Templates tab
- Add capture engine integration to ProcessorManager
- Add CLAUDE.md with git commit/push policy and server restart instructions
- Add en/ru localization for all new UI elements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 02:43:49 +03:00
b5545d3198 Add auto-initialization to MSS and DXcam engines to fix WGC multi-monitor issue
- MSS and DXcam now auto-initialize on first capture_display() call, matching WGC behavior
- Remove engine.initialize() call from test endpoint to prevent WGC from initializing monitor 0 unnecessarily
- This fixes the issue where testing WGC with secondary monitor would show borders on both displays

Previously, calling engine.initialize() would initialize WGC's monitor 0 by default, then capture_display() would initialize the requested monitor, causing both to be active. Now all engines consistently auto-initialize only when needed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-09 19:25:45 +03:00
fdb73c9fc9 Fix MSS template test: add engine initialization and create TemplateStore
- Add engine.initialize() call in test endpoint to fix "Engine not initialized" error for MSS and DXcam
- Create template.py with CaptureTemplate dataclass for template data model
- Create template_store.py with TemplateStore class for template CRUD operations
- TemplateStore loads from capture_templates.json and provides get_all, create, get, update, delete methods

Fixes MSS capture test failing with "Engine not initialized" error. WGC worked because it auto-initializes in capture_display(), but MSS and DXcam require explicit initialization.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-09 19:23:20 +03:00
fd38481e17 Add backdrop click to Add/Edit Template modal, document dialog UI standards
- Add backdrop click handlers to showAddTemplateModal() and editTemplate() to close modal when clicking outside
- Create new "Frontend UI Patterns" section in CLAUDE.md documenting modal dialog standards
- Document backdrop click behavior with code example
- Document close button requirements for dialogs with Cancel buttons

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-09 19:18:21 +03:00
18 changed files with 1123 additions and 105 deletions

86
CLAUDE.md Normal file
View File

@@ -0,0 +1,86 @@
# Claude Instructions for WLED Screen Controller
## CRITICAL: Git Commit and Push Policy
**🚨 NEVER CREATE COMMITS WITHOUT EXPLICIT USER APPROVAL 🚨**
**🚨 NEVER PUSH TO REMOTE WITHOUT EXPLICIT USER APPROVAL 🚨**
### Strict Rules
1. **DO NOT** create commits automatically after making changes
2. **DO NOT** commit without being explicitly instructed by the user
3. **DO NOT** push to remote repository without explicit instruction
4. **ALWAYS WAIT** for the user to review changes and ask you to commit
5. **ALWAYS ASK** if you're unsure whether to commit
### Workflow
1. Make code changes as requested
2. **STOP** - Inform user that changes are complete
3. **WAIT** - User reviews the changes
4. **ONLY IF** user explicitly says "commit" or "create a commit":
- Stage the files with `git add`
- Create the commit with a descriptive message
- **STOP** - Do NOT push
5. **ONLY IF** user explicitly says "push" or "commit and push":
- Push to remote repository
### What Counts as Explicit Approval
**YES - These mean you can commit:**
- "commit"
- "create a commit"
- "commit these changes"
- "git commit"
**YES - These mean you can push:**
- "push"
- "commit and push"
- "push to remote"
- "git push"
**NO - These do NOT mean you should commit:**
- "that looks good"
- "thanks"
- "perfect"
- User silence after you make changes
- Completing a feature/fix
### Example Bad Behavior (DON'T DO THIS)
```
❌ User: "Fix the MSS engine test issue"
❌ Claude: [fixes the issue]
❌ Claude: [automatically commits without asking] <-- WRONG!
```
### Example Good Behavior (DO THIS)
```
✅ User: "Fix the MSS engine test issue"
✅ Claude: [fixes the issue]
✅ Claude: "I've fixed the MSS engine test issue by adding auto-initialization..."
✅ [WAITS FOR USER]
✅ User: "Looks good, commit it"
✅ Claude: [now creates the commit]
```
## Project Structure
This is a monorepo containing:
- `/server` - Python FastAPI backend (see `server/CLAUDE.md` for detailed instructions)
- `/client` - Future frontend client (if applicable)
## Working with Server
For detailed server-specific instructions (restart policy, testing, etc.), see:
- `server/CLAUDE.md`
## General Guidelines
- Always test changes before marking as complete
- Follow existing code style and patterns
- Update documentation when changing behavior
- Write clear, descriptive commit messages when explicitly instructed
- Never make commits or pushes without explicit user approval

View File

@@ -6,6 +6,8 @@
**IMPORTANT**: When making changes to server code (Python files in `src/wled_controller/`), you MUST restart the server if it's currently running to ensure the changes take effect.
**NOTE**: Auto-reload is currently disabled (`reload=False` in `main.py`) due to watchfiles causing an infinite reload loop. Changes to server code will NOT be automatically picked up - manual server restart is required.
#### When to restart:
- After modifying API routes (`api/routes.py`, `api/schemas.py`)
- After updating core logic (`core/*.py`)
@@ -22,12 +24,34 @@ netstat -an | grep 8080
```
#### How to restart:
1. Stop the current server (if running as background task, use TaskStop with the task ID)
2. Start a new server instance:
1. **Find the task ID** of the running server (look for background bash tasks in conversation)
2. **Stop the server** using TaskStop with the task ID
3. **Check for port conflicts** (port 8080 may still be in use):
```bash
netstat -ano | findstr :8080
```
If a process is still using port 8080, kill it:
```bash
powershell -Command "Stop-Process -Id <PID> -Force"
```
4. **Start a new server instance** in the background:
```bash
cd server && python -m wled_controller.main
```
3. Verify the new server started successfully by checking the output logs
Use `run_in_background: true` parameter in Bash tool
5. **Wait 3 seconds** for server to initialize:
```bash
sleep 3
```
6. **Verify startup** by reading the output file:
- Look for "Uvicorn running on http://0.0.0.0:8080"
- Check for any errors in stderr
- Verify "Application startup complete" message
**Common Issues:**
- **Port 8080 in use**: Old process didn't terminate cleanly - kill it manually
- **Module import errors**: Check that all Python files are syntactically correct
- **Permission errors**: Ensure file permissions allow Python to execute
#### Files that DON'T require restart:
- Static files (`static/*.html`, `static/*.css`, `static/*.js`) - these are served directly
@@ -35,24 +59,26 @@ netstat -an | grep 8080
- Documentation files (`*.md`)
- Configuration files in `config/` if server supports hot-reload (check implementation)
### Git Push Policy
### Git Commit and Push Policy
**CRITICAL**: NEVER push commits to the remote repository without explicit user approval.
**CRITICAL**: NEVER commit OR push code changes without explicit user approval.
#### Rules
- You MAY create commits when requested or when appropriate
- You MUST NOT create commits without explicit user instruction
- You MUST NOT push commits unless explicitly instructed by the user
- Wait for the user to review changes and ask you to commit
- If the user says "commit", create a commit but DO NOT push
- If the user says "commit and push", you may push after committing
- Always wait for explicit permission before any push operation
- Always wait for explicit permission before any commit or push operation
#### Workflow
1. Make changes to code
2. Create commit when appropriate (with user consent)
3. **STOP and WAIT** - do not push
4. Only push when user explicitly requests it (e.g., "push", "commit and push", "push to remote")
2. **STOP and WAIT** - inform the user of changes and wait for instruction
3. Only create commit when user explicitly requests it (e.g., "commit", "create a commit")
4. **STOP and WAIT** - do not push
5. Only push when user explicitly requests it (e.g., "push", "commit and push", "push to remote")
### Testing Changes
@@ -102,6 +128,56 @@ After restarting the server with new code:
3. Use `t('key')` function in `static/app.js` for dynamic content
4. No server restart needed (frontend only)
## Frontend UI Patterns
### Modal Dialogs
**IMPORTANT**: All modal dialogs must follow these standards for consistent UX:
#### Backdrop Click Behavior
All modals MUST close when the user clicks outside the dialog (on the backdrop). Implement this by adding a click handler that checks if the clicked element is the modal backdrop itself:
```javascript
// Show modal
const modal = document.getElementById('my-modal');
modal.style.display = 'flex';
// Add backdrop click handler to close modal
modal.onclick = function(event) {
if (event.target === modal) {
closeMyModal();
}
};
```
**Where to add**: In every function that shows a modal (e.g., `showAddTemplateModal()`, `editTemplate()`, `showTestTemplateModal()`).
#### Close Button Requirement
Each modal dialog that has a "Cancel" button MUST also have a cross (×) close button at the top-right corner of the dialog. This provides users with multiple intuitive ways to dismiss the dialog:
1. Click the backdrop (outside the dialog)
2. Click the × button (top-right corner)
3. Click the Cancel button (bottom of dialog)
4. Press Escape key (if implemented)
**HTML Structure**:
```html
<div class="modal-content">
<button class="close-btn" onclick="closeMyModal()">&times;</button>
<h2>Dialog Title</h2>
<!-- dialog content -->
<div class="modal-actions">
<button onclick="closeMyModal()">Cancel</button>
<button onclick="submitAction()">Submit</button>
</div>
</div>
```
**CSS Requirements**:
- Close button should be positioned absolutely at top-right
- Should be easily clickable (min 24px × 24px hit area)
- Should have clear hover state
## Authentication
Server uses API key authentication. Keys are configured in:

View File

@@ -48,6 +48,11 @@ dev = [
"black>=24.0.0",
"ruff>=0.6.0",
]
# High-performance screen capture engines (Windows only)
perf = [
"dxcam>=0.0.5; sys_platform == 'win32'",
"windows-capture>=1.5.0; sys_platform == 'win32'",
]
[project.urls]
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"

View File

@@ -176,6 +176,7 @@ async def create_device(
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
template_store: TemplateStore = Depends(get_template_store),
):
"""Create and attach a new WLED device."""
try:
@@ -214,11 +215,29 @@ async def create_device(
detail=f"Failed to connect to WLED device at {device_url}: {e}"
)
# Resolve capture template: use requested ID if valid, else first available
capture_template_id = None
if device_data.capture_template_id:
try:
template_store.get_template(device_data.capture_template_id)
capture_template_id = device_data.capture_template_id
except ValueError:
logger.warning(
f"Requested template '{device_data.capture_template_id}' not found, using first available"
)
if not capture_template_id:
all_templates = template_store.get_all_templates()
if all_templates:
capture_template_id = all_templates[0].id
else:
capture_template_id = "tpl_mss_default"
# Create device in storage (LED count auto-detected from WLED)
device = store.create_device(
name=device_data.name,
url=device_data.url,
led_count=wled_led_count,
capture_template_id=capture_template_id,
)
# Add to processor manager
@@ -930,7 +949,7 @@ async def test_template(
)
)
# Create engine (initialization happens on first capture)
# Create engine (auto-initializes on first capture)
engine = EngineRegistry.create_engine(test_request.engine_type, test_request.engine_config)
# Run sustained capture test

View File

@@ -55,6 +55,7 @@ class DeviceCreate(BaseModel):
name: str = Field(description="Device name", min_length=1, max_length=100)
url: str = Field(description="WLED device URL (e.g., http://192.168.1.100)")
capture_template_id: Optional[str] = Field(None, description="Capture template ID (uses first available if not set or invalid)")
class DeviceUpdate(BaseModel):
@@ -63,6 +64,7 @@ class DeviceUpdate(BaseModel):
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field(None, description="WLED device URL")
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
class ColorCorrection(BaseModel):
@@ -153,6 +155,7 @@ class DeviceResponse(BaseModel):
)
settings: ProcessingSettings = Field(description="Processing settings")
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
capture_template_id: str = Field(description="ID of assigned capture template")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -210,3 +213,110 @@ class ErrorResponse(BaseModel):
message: str = Field(description="Error message")
detail: Optional[Dict] = Field(None, description="Additional error details")
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Error timestamp")
# Capture Template Schemas
class TemplateCreate(BaseModel):
"""Request to create a capture template."""
name: str = Field(description="Template name", min_length=1, max_length=100)
engine_type: str = Field(description="Engine type (e.g., 'mss', 'dxcam', 'wgc')", min_length=1)
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
class TemplateUpdate(BaseModel):
"""Request to update a template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
class TemplateResponse(BaseModel):
"""Template information response."""
id: str = Field(description="Template ID")
name: str = Field(description="Template name")
engine_type: str = Field(description="Engine type identifier")
engine_config: Dict = Field(description="Engine-specific configuration")
is_default: bool = Field(description="Whether this is a system default template")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
class TemplateListResponse(BaseModel):
"""List of templates response."""
templates: List[TemplateResponse] = Field(description="List of templates")
count: int = Field(description="Number of templates")
class EngineInfo(BaseModel):
"""Capture engine information."""
type: str = Field(description="Engine type identifier (e.g., 'mss', 'dxcam')")
name: str = Field(description="Human-readable engine name")
default_config: Dict = Field(description="Default configuration for this engine")
available: bool = Field(description="Whether engine is available on this system")
class EngineListResponse(BaseModel):
"""List of available engines response."""
engines: List[EngineInfo] = Field(description="Available capture engines")
count: int = Field(description="Number of engines")
class TemplateAssignment(BaseModel):
"""Request to assign template to device."""
template_id: str = Field(description="Template ID to assign")
class TemplateTestRequest(BaseModel):
"""Request to test a capture template."""
engine_type: str = Field(description="Capture engine type to test")
engine_config: Dict = Field(default={}, description="Engine configuration")
display_index: int = Field(description="Display index to capture")
border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels")
capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds")
class CaptureImage(BaseModel):
"""Captured image with metadata."""
image: str = Field(description="Base64-encoded image data")
width: int = Field(description="Image width in pixels")
height: int = Field(description="Image height in pixels")
thumbnail_width: Optional[int] = Field(None, description="Thumbnail width (if resized)")
thumbnail_height: Optional[int] = Field(None, description="Thumbnail height (if resized)")
class BorderExtraction(BaseModel):
"""Extracted border images."""
top: str = Field(description="Base64-encoded top border image")
right: str = Field(description="Base64-encoded right border image")
bottom: str = Field(description="Base64-encoded bottom border image")
left: str = Field(description="Base64-encoded left border image")
class PerformanceMetrics(BaseModel):
"""Performance metrics for template test."""
capture_duration_s: float = Field(description="Total capture duration in seconds")
frame_count: int = Field(description="Number of frames captured")
actual_fps: float = Field(description="Actual FPS (frame_count / duration)")
avg_capture_time_ms: float = Field(description="Average time per frame capture in milliseconds")
class TemplateTestResponse(BaseModel):
"""Response from template test."""
full_capture: CaptureImage = Field(description="Full screen capture with thumbnail")
border_extraction: Optional[BorderExtraction] = Field(None, description="Extracted border images (deprecated)")
performance: PerformanceMetrics = Field(description="Performance metrics")

View File

@@ -54,6 +54,7 @@ class StorageConfig(BaseSettings):
"""Storage configuration."""
devices_file: str = "data/devices.json"
templates_file: str = "data/capture_templates.json"
class LoggingConfig(BaseSettings):

View File

@@ -168,12 +168,12 @@ class DXcamEngine(CaptureEngine):
ScreenCapture object with image data
Raises:
RuntimeError: If not initialized
ValueError: If display_index doesn't match configured output
RuntimeError: If capture fails
"""
# Auto-initialize if not already initialized
if not self._initialized:
raise RuntimeError("Engine not initialized")
self.initialize()
# DXcam is configured for a specific output
configured_output = self.config.get("output_idx", 0)

View File

@@ -115,12 +115,12 @@ class MSSEngine(CaptureEngine):
ScreenCapture object with image data
Raises:
RuntimeError: If not initialized
ValueError: If display_index is invalid
RuntimeError: If capture fails
"""
# Auto-initialize if not already initialized
if not self._initialized:
raise RuntimeError("Engine not initialized")
self.initialize()
try:
# mss monitors[0] is the combined screen, monitors[1+] are individual displays

View File

@@ -13,8 +13,9 @@ from wled_controller.core.calibration import (
PixelMapper,
create_default_calibration,
)
from wled_controller.core.capture_engines import CaptureEngine, EngineRegistry
from wled_controller.core.pixel_processor import apply_color_correction, smooth_colors
from wled_controller.core.screen_capture import capture_display, extract_border_pixels
from wled_controller.core.screen_capture import extract_border_pixels
from wled_controller.core.wled_client import WLEDClient
from wled_controller.utils import get_logger
@@ -87,8 +88,10 @@ class ProcessorState:
led_count: int
settings: ProcessingSettings
calibration: CalibrationConfig
capture_template_id: str = "tpl_mss_default" # NEW: template ID for capture engine
wled_client: Optional[WLEDClient] = None
pixel_mapper: Optional[PixelMapper] = None
capture_engine: Optional[CaptureEngine] = None # NEW: initialized capture engine
is_running: bool = False
task: Optional[asyncio.Task] = None
metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics)
@@ -122,6 +125,7 @@ class ProcessorManager:
led_count: int,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
capture_template_id: str = "tpl_mss_default",
):
"""Add a device for processing.
@@ -131,6 +135,7 @@ class ProcessorManager:
led_count: Number of LEDs
settings: Processing settings (uses defaults if None)
calibration: Calibration config (creates default if None)
capture_template_id: Template ID for screen capture engine
"""
if device_id in self._processors:
raise ValueError(f"Device {device_id} already exists")
@@ -147,6 +152,7 @@ class ProcessorManager:
led_count=led_count,
settings=settings,
calibration=calibration,
capture_template_id=capture_template_id,
)
self._processors[device_id] = state
@@ -270,6 +276,21 @@ class ProcessorManager:
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
raise RuntimeError(f"Failed to connect to WLED device: {e}")
# Initialize capture engine
# Phase 2: Use MSS engine for all devices (template integration in Phase 5)
try:
# For now, always use MSS engine (Phase 5 will load from template)
engine = EngineRegistry.create_engine("mss", {})
engine.initialize()
state.capture_engine = engine
logger.debug(f"Initialized capture engine for device {device_id}: mss")
except Exception as e:
logger.error(f"Failed to initialize capture engine for device {device_id}: {e}")
# Cleanup WLED client before raising
if state.wled_client:
await state.wled_client.disconnect()
raise RuntimeError(f"Failed to initialize capture engine: {e}")
# Initialize pixel mapper
state.pixel_mapper = PixelMapper(
state.calibration,
@@ -321,6 +342,11 @@ class ProcessorManager:
await state.wled_client.close()
state.wled_client = None
# Cleanup capture engine
if state.capture_engine:
state.capture_engine.cleanup()
state.capture_engine = None
logger.info(f"Stopped processing for device {device_id}")
async def _processing_loop(self, device_id: str):
@@ -351,8 +377,11 @@ class ProcessorManager:
try:
# Run blocking operations in thread pool to avoid blocking event loop
# Capture screen (blocking I/O)
capture = await asyncio.to_thread(capture_display, settings.display_index)
# Capture screen using engine (blocking I/O)
capture = await asyncio.to_thread(
state.capture_engine.capture_display,
settings.display_index
)
# Extract border pixels (CPU-intensive)
border_pixels = await asyncio.to_thread(extract_border_pixels, capture, settings.border_width)
@@ -725,3 +754,31 @@ class ProcessorManager:
"wled_led_type": h.wled_led_type,
"error": h.error,
}
def is_display_locked(self, display_index: int) -> bool:
"""Check if a display is currently being captured by any device.
Args:
display_index: Display index to check
Returns:
True if the display is actively being captured
"""
for state in self._processors.values():
if state.is_running and state.settings.display_index == display_index:
return True
return False
def get_display_lock_info(self, display_index: int) -> Optional[str]:
"""Get the device ID that is currently capturing from a display.
Args:
display_index: Display index to check
Returns:
Device ID if locked, None otherwise
"""
for device_id, state in self._processors.items():
if state.is_running and state.settings.display_index == display_index:
return device_id
return None

View File

@@ -15,6 +15,7 @@ from wled_controller.api.routes import init_dependencies
from wled_controller.config import get_config
from wled_controller.core.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.utils import setup_logging, get_logger
# Initialize logging
@@ -26,6 +27,7 @@ config = get_config()
# Initialize storage and processing
device_store = DeviceStore(config.storage.devices_file)
template_store = TemplateStore(config.storage.templates_file)
processor_manager = ProcessorManager()
@@ -59,7 +61,7 @@ async def lifespan(app: FastAPI):
logger.info("All API requests require valid Bearer token authentication")
# Initialize API dependencies
init_dependencies(device_store, processor_manager)
init_dependencies(device_store, template_store, processor_manager)
# Load existing devices into processor manager
devices = device_store.get_all_devices()
@@ -71,6 +73,7 @@ async def lifespan(app: FastAPI):
led_count=device.led_count,
settings=device.settings,
calibration=device.calibration,
capture_template_id=device.capture_template_id,
)
logger.info(f"Loaded device: {device.name} ({device.id})")
except Exception as e:
@@ -165,5 +168,5 @@ if __name__ == "__main__":
host=config.server.host,
port=config.server.port,
log_level=config.server.log_level.lower(),
reload=True,
reload=False, # Disabled due to watchfiles infinite reload loop
)

View File

@@ -630,6 +630,9 @@ function createDeviceCard(device) {
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
⚙️
</button>
<button class="btn btn-icon btn-secondary" onclick="showCaptureSettings('${device.id}')" title="${t('device.button.capture_settings')}">
🎬
</button>
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
📐
</button>
@@ -737,12 +740,7 @@ async function removeDevice(deviceId) {
async function showSettings(deviceId) {
try {
// Fetch device data, displays, and templates in parallel
const [deviceResponse, displaysResponse, templatesResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
fetchWithAuth('/capture-templates'),
]);
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() });
if (deviceResponse.status === 401) {
handle401Error();
@@ -756,48 +754,7 @@ async function showSettings(deviceId) {
const device = await deviceResponse.json();
// 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 capture template select
const templateSelect = document.getElementById('settings-capture-template');
templateSelect.innerHTML = '';
if (templatesResponse.ok) {
const templatesData = await templatesResponse.json();
(templatesData.templates || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
const engineIcon = getEngineIcon(t.engine_type);
opt.textContent = `${engineIcon} ${t.name}`;
templateSelect.appendChild(opt);
});
}
if (templateSelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = 'tpl_mss_default';
opt.textContent = 'MSS (Default)';
templateSelect.appendChild(opt);
}
templateSelect.value = device.capture_template_id || 'tpl_mss_default';
// Populate other fields
// Populate 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;
@@ -807,9 +764,7 @@ async function showSettings(deviceId) {
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),
capture_template_id: device.capture_template_id || 'tpl_mss_default',
};
// Show modal
@@ -832,9 +787,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 ||
document.getElementById('settings-capture-template').value !== settingsInitialValues.capture_template_id
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
);
}
@@ -859,9 +812,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 capture_template_id = document.getElementById('settings-capture-template').value;
const error = document.getElementById('settings-error');
// Validation
@@ -872,11 +823,11 @@ async function saveDeviceSettings() {
}
try {
// Update device info (name, url, capture_template_id)
// Update device info (name, url)
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ name, url, capture_template_id })
body: JSON.stringify({ name, url })
});
if (deviceResponse.status === 401) {
@@ -895,7 +846,7 @@ async function saveDeviceSettings() {
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ display_index, state_check_interval })
body: JSON.stringify({ state_check_interval })
});
if (settingsResponse.status === 401) {
@@ -904,7 +855,7 @@ async function saveDeviceSettings() {
}
if (settingsResponse.ok) {
showToast('Device settings updated', 'success');
showToast(t('settings.saved'), 'success');
forceCloseDeviceSettingsModal();
loadDevices();
} else {
@@ -919,6 +870,170 @@ async function saveDeviceSettings() {
}
}
// ===== Capture Settings Modal =====
let captureSettingsInitialValues = {};
async function showCaptureSettings(deviceId) {
try {
// Fetch device data, displays, and templates in parallel
const [deviceResponse, displaysResponse, templatesResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
fetchWithAuth('/capture-templates'),
]);
if (deviceResponse.status === 401) {
handle401Error();
return;
}
if (!deviceResponse.ok) {
showToast('Failed to load capture settings', 'error');
return;
}
const device = await deviceResponse.json();
// Populate display index select
const displaySelect = document.getElementById('capture-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 capture template select
const templateSelect = document.getElementById('capture-settings-template');
templateSelect.innerHTML = '';
if (templatesResponse.ok) {
const templatesData = await templatesResponse.json();
(templatesData.templates || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
const engineIcon = getEngineIcon(t.engine_type);
opt.textContent = `${engineIcon} ${t.name}`;
templateSelect.appendChild(opt);
});
}
if (templateSelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = 'tpl_mss_default';
opt.textContent = 'MSS (Default)';
templateSelect.appendChild(opt);
}
templateSelect.value = device.capture_template_id || 'tpl_mss_default';
// Store device ID and snapshot initial values
document.getElementById('capture-settings-device-id').value = device.id;
captureSettingsInitialValues = {
display_index: String(device.settings.display_index ?? 0),
capture_template_id: device.capture_template_id || 'tpl_mss_default',
};
// Show modal
const modal = document.getElementById('capture-settings-modal');
modal.style.display = 'flex';
lockBody();
} catch (error) {
console.error('Failed to load capture settings:', error);
showToast('Failed to load capture settings', 'error');
}
}
function isCaptureSettingsDirty() {
return (
document.getElementById('capture-settings-display-index').value !== captureSettingsInitialValues.display_index ||
document.getElementById('capture-settings-template').value !== captureSettingsInitialValues.capture_template_id
);
}
function forceCloseCaptureSettingsModal() {
const modal = document.getElementById('capture-settings-modal');
const error = document.getElementById('capture-settings-error');
modal.style.display = 'none';
error.style.display = 'none';
unlockBody();
captureSettingsInitialValues = {};
}
async function closeCaptureSettingsModal() {
if (isCaptureSettingsDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseCaptureSettingsModal();
}
async function saveCaptureSettings() {
const deviceId = document.getElementById('capture-settings-device-id').value;
const display_index = parseInt(document.getElementById('capture-settings-display-index').value) || 0;
const capture_template_id = document.getElementById('capture-settings-template').value;
const error = document.getElementById('capture-settings-error');
try {
// Update capture template on device
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ capture_template_id })
});
if (deviceResponse.status === 401) {
handle401Error();
return;
}
if (!deviceResponse.ok) {
const errorData = await deviceResponse.json();
error.textContent = `Failed to update capture template: ${errorData.detail}`;
error.style.display = 'block';
return;
}
// Update display index in settings
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ display_index })
});
if (settingsResponse.status === 401) {
handle401Error();
return;
}
if (settingsResponse.ok) {
// Remember last used template for new device creation
localStorage.setItem('lastCaptureTemplateId', capture_template_id);
showToast(t('settings.capture.saved'), 'success');
forceCloseCaptureSettingsModal();
loadDevices();
} else {
const errorData = await settingsResponse.json();
error.textContent = `Failed to update settings: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
console.error('Failed to save capture settings:', err);
error.textContent = t('settings.capture.failed');
error.style.display = 'block';
}
}
// Card brightness controls
function updateBrightnessLabel(deviceId, value) {
const slider = document.querySelector(`[data-device-id="${deviceId}"] .brightness-slider`);
@@ -971,10 +1086,16 @@ async function handleAddDevice(event) {
}
try {
const body = { name, url };
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
if (lastTemplateId) {
body.capture_template_id = lastTemplateId;
}
const response = await fetch(`${API_BASE}/devices`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ name, url })
body: JSON.stringify(body)
});
if (response.status === 401) {
@@ -1928,12 +2049,18 @@ document.addEventListener('click', (e) => {
return;
}
// Settings modal: dirty check
// General settings modal: dirty check
if (modalId === 'device-settings-modal') {
closeDeviceSettingsModal();
return;
}
// Capture settings modal: dirty check
if (modalId === 'capture-settings-modal') {
closeCaptureSettingsModal();
return;
}
// Calibration modal: dirty check
if (modalId === 'calibration-modal') {
closeCalibrationModal();
@@ -1977,8 +2104,9 @@ const deviceTutorialSteps = [
{ selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' },
{ selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' },
{ selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.calibrate', position: 'top' },
{ selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.webui', position: 'top' }
{ selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' },
{ selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' }
];
function startTutorial(config) {
@@ -2218,7 +2346,10 @@ function renderTemplatesList(templates) {
return;
}
container.innerHTML = templates.map(template => {
const defaultTemplates = templates.filter(t => t.is_default);
const customTemplates = templates.filter(t => !t.is_default);
const renderCard = (template) => {
const engineIcon = getEngineIcon(template.engine_type);
const defaultBadge = template.is_default
? `<span class="badge badge-default">${t('templates.default')}</span>`
@@ -2258,10 +2389,21 @@ function renderTemplatesList(templates) {
</div>
</div>
`;
}).join('') + `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
};
let html = defaultTemplates.map(renderCard).join('');
if (customTemplates.length > 0) {
html += `<div class="templates-separator"><span>${t('templates.custom')}</span></div>`;
html += customTemplates.map(renderCard).join('');
}
html += `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('templates.add')}</div>
</div>`;
container.innerHTML = html;
}
// Get engine icon
@@ -2281,7 +2423,16 @@ async function showAddTemplateModal() {
// Load available engines
await loadAvailableEngines();
document.getElementById('template-modal').style.display = 'flex';
// Show modal
const modal = document.getElementById('template-modal');
modal.style.display = 'flex';
// Add backdrop click handler to close modal
modal.onclick = function(event) {
if (event.target === modal) {
closeTemplateModal();
}
};
}
// Edit template
@@ -2315,7 +2466,16 @@ async function editTemplate(templateId) {
document.getElementById('template-test-results').style.display = 'none';
document.getElementById('template-error').style.display = 'none';
document.getElementById('template-modal').style.display = 'flex';
// Show modal
const modal = document.getElementById('template-modal');
modal.style.display = 'flex';
// Add backdrop click handler to close modal
modal.onclick = function(event) {
if (event.target === modal) {
closeTemplateModal();
}
};
} catch (error) {
console.error('Error loading template:', error);
showToast(t('templates.error.load') + ': ' + error.message, 'error');

View File

@@ -206,11 +206,11 @@
</div>
</div>
<!-- Device Settings Modal -->
<!-- General Settings Modal -->
<div id="device-settings-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 data-i18n="settings.title">⚙️ Device Settings</h2>
<h2 data-i18n="settings.general.title">⚙️ General Settings</h2>
<button class="modal-close-btn" onclick="closeDeviceSettingsModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
@@ -228,18 +228,6 @@
<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-capture-template" data-i18n="settings.capture_template">Capture Template:</label>
<select id="settings-capture-template"></select>
<small class="input-hint" data-i18n="settings.capture_template.hint">Screen capture engine and configuration for this device</small>
</div>
<div class="form-group">
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
@@ -256,6 +244,39 @@
</div>
</div>
<!-- Capture Settings Modal -->
<div id="capture-settings-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 data-i18n="settings.capture.title">🎬 Capture Settings</h2>
<button class="modal-close-btn" onclick="closeCaptureSettingsModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="capture-settings-form">
<input type="hidden" id="capture-settings-device-id">
<div class="form-group">
<label for="capture-settings-display-index" data-i18n="settings.display_index">Display:</label>
<select id="capture-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="capture-settings-template" data-i18n="settings.capture_template">Capture Template:</label>
<select id="capture-settings-template"></select>
<small class="input-hint" data-i18n="settings.capture_template.hint">Screen capture engine and configuration for this device</small>
</div>
<div id="capture-settings-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeCaptureSettingsModal()" title="Cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveCaptureSettings()" title="Save">&#x2713;</button>
</div>
</div>
</div>
<!-- Login Modal -->
<div id="api-key-modal" class="modal">
<div class="modal-content">

View File

@@ -32,6 +32,61 @@
"displays.loading": "Loading displays...",
"displays.none": "No displays available",
"displays.failed": "Failed to load displays",
"templates.title": "\uD83C\uDFAF Capture Templates",
"templates.description": "Capture templates define how the screen is captured. Each template uses a specific capture engine (MSS, DXcam, WGC) with custom settings. Assign templates to devices for optimal performance.",
"templates.loading": "Loading templates...",
"templates.empty": "No capture templates configured",
"templates.add": "Add Capture Template",
"templates.edit": "Edit Capture Template",
"templates.name": "Template Name:",
"templates.name.placeholder": "My Custom Template",
"templates.description.label": "Description (optional):",
"templates.description.placeholder": "Describe this template...",
"templates.engine": "Capture Engine:",
"templates.engine.select": "Select an engine...",
"templates.engine.unavailable": "Unavailable",
"templates.engine.unavailable.hint": "This engine is not available on your system",
"templates.config": "Engine Configuration",
"templates.config.show": "Show configuration",
"templates.config.none": "No additional configuration",
"templates.config.default": "Default",
"templates.default": "Default",
"templates.custom": "Custom Templates",
"templates.default.locked": "Default template (cannot edit/delete)",
"templates.created": "Template created successfully",
"templates.updated": "Template updated successfully",
"templates.deleted": "Template deleted successfully",
"templates.delete.confirm": "Are you sure you want to delete this template?",
"templates.error.load": "Failed to load templates",
"templates.error.engines": "Failed to load engines",
"templates.error.required": "Please fill in all required fields",
"templates.error.delete": "Failed to delete template",
"templates.test.title": "Test Capture",
"templates.test.description": "Test this template before saving to see a capture preview and performance metrics.",
"templates.test.display": "Display:",
"templates.test.display.select": "Select display...",
"templates.test.duration": "Capture Duration (s):",
"templates.test.border_width": "Border Width (px):",
"templates.test.run": "\uD83E\uDDEA Run Test",
"templates.test.running": "Running test...",
"templates.test.results.preview": "Full Capture Preview",
"templates.test.results.borders": "Border Extraction",
"templates.test.results.top": "Top",
"templates.test.results.right": "Right",
"templates.test.results.bottom": "Bottom",
"templates.test.results.left": "Left",
"templates.test.results.performance": "Performance",
"templates.test.results.capture_time": "Capture",
"templates.test.results.extraction_time": "Extraction",
"templates.test.results.total_time": "Total",
"templates.test.results.max_fps": "Max FPS",
"templates.test.results.duration": "Duration",
"templates.test.results.frame_count": "Frames",
"templates.test.results.actual_fps": "Actual FPS",
"templates.test.results.avg_capture_time": "Avg Capture",
"templates.test.error.no_engine": "Please select a capture engine",
"templates.test.error.no_display": "Please select a display",
"templates.test.error.failed": "Test failed",
"devices.title": "\uD83D\uDCA1 Devices",
"devices.add": "Add New Device",
"devices.loading": "Loading devices...",
@@ -54,7 +109,8 @@
"device.button.add": "Add Device",
"device.button.start": "Start",
"device.button.stop": "Stop",
"device.button.settings": "Settings",
"device.button.settings": "General Settings",
"device.button.capture_settings": "Capture Settings",
"device.button.calibrate": "Calibrate",
"device.button.remove": "Remove",
"device.button.webui": "Open WLED Web UI",
@@ -81,16 +137,23 @@
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from WLED",
"device.tip.brightness": "Slide to adjust device brightness",
"device.tip.start": "Start or stop screen capture processing",
"device.tip.settings": "Configure device settings (display, FPS, health check)",
"device.tip.settings": "Configure general device settings (name, URL, health check)",
"device.tip.capture_settings": "Configure capture settings (display, capture template)",
"device.tip.calibrate": "Calibrate LED positions, direction, and coverage",
"device.tip.webui": "Open WLED's built-in web interface for advanced configuration",
"device.tip.add": "Click here to add a new WLED device",
"settings.title": "Device Settings",
"settings.general.title": "General Settings",
"settings.capture.title": "Capture Settings",
"settings.capture.saved": "Capture settings updated",
"settings.capture.failed": "Failed to save capture settings",
"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.capture_template": "Capture Template:",
"settings.capture_template.hint": "Screen capture engine and configuration 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)",

View File

@@ -32,6 +32,61 @@
"displays.loading": "Загрузка дисплеев...",
"displays.none": "Нет доступных дисплеев",
"displays.failed": "Не удалось загрузить дисплеи",
"templates.title": "\uD83C\uDFAF Шаблоны Захвата",
"templates.description": "Шаблоны захвата определяют, как захватывается экран. Каждый шаблон использует определённый движок захвата (MSS, DXcam, WGC) с настраиваемыми параметрами. Назначайте шаблоны устройствам для оптимальной производительности.",
"templates.loading": "Загрузка шаблонов...",
"templates.empty": "Шаблоны захвата не настроены",
"templates.add": "Добавить Шаблон Захвата",
"templates.edit": "Редактировать Шаблон Захвата",
"templates.name": "Имя Шаблона:",
"templates.name.placeholder": "Мой Пользовательский Шаблон",
"templates.description.label": "Описание (необязательно):",
"templates.description.placeholder": "Опишите этот шаблон...",
"templates.engine": "Движок Захвата:",
"templates.engine.select": "Выберите движок...",
"templates.engine.unavailable": "Недоступен",
"templates.engine.unavailable.hint": "Этот движок недоступен в вашей системе",
"templates.config": "Конфигурация Движка",
"templates.config.show": "Показать конфигурацию",
"templates.config.none": "Нет дополнительных настроек",
"templates.config.default": "По умолчанию",
"templates.default": "По умолчанию",
"templates.custom": "Пользовательские шаблоны",
"templates.default.locked": "Системный шаблон (нельзя редактировать/удалить)",
"templates.created": "Шаблон успешно создан",
"templates.updated": "Шаблон успешно обновлён",
"templates.deleted": "Шаблон успешно удалён",
"templates.delete.confirm": "Вы уверены, что хотите удалить этот шаблон?",
"templates.error.load": "Не удалось загрузить шаблоны",
"templates.error.engines": "Не удалось загрузить движки",
"templates.error.required": "Пожалуйста, заполните все обязательные поля",
"templates.error.delete": "Не удалось удалить шаблон",
"templates.test.title": "Тест Захвата",
"templates.test.description": "Протестируйте этот шаблон перед сохранением, чтобы увидеть предпросмотр захвата и метрики производительности.",
"templates.test.display": "Дисплей:",
"templates.test.display.select": "Выберите дисплей...",
"templates.test.duration": "Длительность Захвата (с):",
"templates.test.border_width": "Ширина Границы (px):",
"templates.test.run": "\uD83E\uDDEA Запустить Тест",
"templates.test.running": "Выполняется тест...",
"templates.test.results.preview": "Полный Предпросмотр Захвата",
"templates.test.results.borders": "Извлечение Границ",
"templates.test.results.top": "Сверху",
"templates.test.results.right": "Справа",
"templates.test.results.bottom": "Снизу",
"templates.test.results.left": "Слева",
"templates.test.results.performance": "Производительность",
"templates.test.results.capture_time": "Захват",
"templates.test.results.extraction_time": "Извлечение",
"templates.test.results.total_time": "Всего",
"templates.test.results.max_fps": "Макс. FPS",
"templates.test.results.duration": "Длительность",
"templates.test.results.frame_count": "Кадры",
"templates.test.results.actual_fps": "Факт. FPS",
"templates.test.results.avg_capture_time": "Средн. Захват",
"templates.test.error.no_engine": "Пожалуйста, выберите движок захвата",
"templates.test.error.no_display": "Пожалуйста, выберите дисплей",
"templates.test.error.failed": "Тест не удался",
"devices.title": "\uD83D\uDCA1 Устройства",
"devices.add": "Добавить Новое Устройство",
"devices.loading": "Загрузка устройств...",
@@ -54,7 +109,8 @@
"device.button.add": "Добавить Устройство",
"device.button.start": "Запустить",
"device.button.stop": "Остановить",
"device.button.settings": "Настройки",
"device.button.settings": "Основные настройки",
"device.button.capture_settings": "Настройки захвата",
"device.button.calibrate": "Калибровка",
"device.button.remove": "Удалить",
"device.button.webui": "Открыть веб-интерфейс WLED",
@@ -81,16 +137,23 @@
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически из WLED",
"device.tip.brightness": "Перетащите для регулировки яркости",
"device.tip.start": "Запуск или остановка захвата экрана",
"device.tip.settings": "Настройки устройства (дисплей, FPS, интервал проверки)",
"device.tip.settings": "Основные настройки устройства (имя, URL, интервал проверки)",
"device.tip.capture_settings": "Настройки захвата (дисплей, шаблон захвата)",
"device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия",
"device.tip.webui": "Открыть встроенный веб-интерфейс WLED для расширенной настройки",
"device.tip.add": "Нажмите, чтобы добавить новое WLED устройство",
"settings.title": "Настройки Устройства",
"settings.general.title": "Основные Настройки",
"settings.capture.title": "Настройки Захвата",
"settings.capture.saved": "Настройки захвата обновлены",
"settings.capture.failed": "Не удалось сохранить настройки захвата",
"settings.brightness": "Яркость:",
"settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)",
"settings.url.hint": "IP адрес или имя хоста вашего WLED устройства",
"settings.display_index": "Дисплей:",
"settings.display_index.hint": "Какой экран захватывать для этого устройства",
"settings.capture_template": "Шаблон Захвата:",
"settings.capture_template.hint": "Движок захвата экрана и конфигурация для этого устройства",
"settings.button.cancel": "Отмена",
"settings.health_interval": "Интервал Проверки (с):",
"settings.health_interval.hint": "Как часто проверять статус WLED устройства (5-600 секунд)",

View File

@@ -1711,6 +1711,24 @@ input:-webkit-autofill:focus {
gap: 20px;
}
.templates-separator {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 12px;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 500;
}
.templates-separator::before,
.templates-separator::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-color);
}
.template-card {
background: var(--card-bg);
border: 1px solid var(--border-color);

View File

@@ -30,6 +30,7 @@ class Device:
enabled: bool = True,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
capture_template_id: str = "tpl_mss_default",
created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None,
):
@@ -43,6 +44,7 @@ class Device:
enabled: Whether device is enabled
settings: Processing settings
calibration: Calibration configuration
capture_template_id: ID of assigned capture template
created_at: Creation timestamp
updated_at: Last update timestamp
"""
@@ -53,6 +55,7 @@ class Device:
self.enabled = enabled
self.settings = settings or ProcessingSettings()
self.calibration = calibration or create_default_calibration(led_count)
self.capture_template_id = capture_template_id
self.created_at = created_at or datetime.utcnow()
self.updated_at = updated_at or datetime.utcnow()
@@ -80,6 +83,7 @@ class Device:
"state_check_interval": self.settings.state_check_interval,
},
"calibration": calibration_to_dict(self.calibration),
"capture_template_id": self.capture_template_id,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@@ -117,6 +121,12 @@ class Device:
else create_default_calibration(data["led_count"])
)
# Migration: assign default MSS template if no template set
capture_template_id = data.get("capture_template_id")
if not capture_template_id:
capture_template_id = "tpl_mss_default"
logger.info(f"Migrating device {data['id']} to default MSS template")
return cls(
device_id=data["id"],
name=data["name"],
@@ -125,6 +135,7 @@ class Device:
enabled=data.get("enabled", True),
settings=settings,
calibration=calibration,
capture_template_id=capture_template_id,
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)
@@ -206,6 +217,7 @@ class DeviceStore:
led_count: int,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
capture_template_id: str = "tpl_mss_default",
) -> Device:
"""Create a new device.
@@ -215,6 +227,7 @@ class DeviceStore:
led_count: Number of LEDs
settings: Processing settings
calibration: Calibration configuration
capture_template_id: ID of assigned capture template
Returns:
Created device
@@ -233,6 +246,7 @@ class DeviceStore:
led_count=led_count,
settings=settings,
calibration=calibration,
capture_template_id=capture_template_id,
)
# Store
@@ -270,6 +284,7 @@ class DeviceStore:
enabled: Optional[bool] = None,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
capture_template_id: Optional[str] = None,
) -> Device:
"""Update device.
@@ -281,6 +296,7 @@ class DeviceStore:
enabled: New enabled state (optional)
settings: New settings (optional)
calibration: New calibration (optional)
capture_template_id: New capture template ID (optional)
Returns:
Updated device
@@ -313,6 +329,8 @@ class DeviceStore:
f"does not match device LED count ({device.led_count})"
)
device.calibration = calibration
if capture_template_id is not None:
device.capture_template_id = capture_template_id
device.updated_at = datetime.utcnow()

View File

@@ -0,0 +1,61 @@
"""Capture template data model."""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, Optional
@dataclass
class CaptureTemplate:
"""Represents a screen capture template configuration."""
id: str
name: str
engine_type: str
engine_config: Dict[str, Any]
is_default: bool
created_at: datetime
updated_at: datetime
description: Optional[str] = None
def to_dict(self) -> dict:
"""Convert template to dictionary.
Returns:
Dictionary representation
"""
return {
"id": self.id,
"name": self.name,
"engine_type": self.engine_type,
"engine_config": self.engine_config,
"is_default": self.is_default,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"description": self.description,
}
@classmethod
def from_dict(cls, data: dict) -> "CaptureTemplate":
"""Create template from dictionary.
Args:
data: Dictionary with template data
Returns:
CaptureTemplate instance
"""
return cls(
id=data["id"],
name=data["name"],
engine_type=data["engine_type"],
engine_config=data.get("engine_config", {}),
is_default=data.get("is_default", False),
created_at=datetime.fromisoformat(data["created_at"])
if isinstance(data.get("created_at"), str)
else data.get("created_at", datetime.utcnow()),
updated_at=datetime.fromisoformat(data["updated_at"])
if isinstance(data.get("updated_at"), str)
else data.get("updated_at", datetime.utcnow()),
description=data.get("description"),
)

View File

@@ -0,0 +1,257 @@
"""Template storage using JSON files."""
import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.core.capture_engines.factory import EngineRegistry
from wled_controller.storage.template import CaptureTemplate
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class TemplateStore:
"""Storage for capture templates.
Default templates for each available engine are created in memory at startup.
Only user-created templates are persisted to the JSON file.
"""
def __init__(self, file_path: str):
"""Initialize template store.
Args:
file_path: Path to templates JSON file
"""
self.file_path = Path(file_path)
self._templates: Dict[str, CaptureTemplate] = {}
self._ensure_defaults()
self._load()
def _ensure_defaults(self) -> None:
"""Create default templates in memory for all available engines."""
available = EngineRegistry.get_available_engines()
now = datetime.utcnow()
for engine_type in available:
template_id = f"tpl_{engine_type}_default"
engine_class = EngineRegistry.get_engine(engine_type)
default_config = engine_class.get_default_config()
self._templates[template_id] = CaptureTemplate(
id=template_id,
name=engine_type.upper(),
engine_type=engine_type,
engine_config=default_config,
is_default=True,
created_at=now,
updated_at=now,
description=f"Default {engine_type} capture template",
)
logger.info(f"Created {len(available)} default templates in memory")
def _load(self) -> None:
"""Load user-created templates from file."""
if not self.file_path.exists():
return
try:
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
templates_data = data.get("templates", {})
loaded = 0
for template_id, template_dict in templates_data.items():
# Skip any default templates that may exist in old files
if template_dict.get("is_default", False):
continue
try:
template = CaptureTemplate.from_dict(template_dict)
self._templates[template_id] = template
loaded += 1
except Exception as e:
logger.error(
f"Failed to load template {template_id}: {e}",
exc_info=True
)
if loaded > 0:
logger.info(f"Loaded {loaded} user templates from storage")
except Exception as e:
logger.error(f"Failed to load templates from {self.file_path}: {e}")
raise
total = len(self._templates)
logger.info(f"Template store initialized with {total} templates")
def _save(self) -> None:
"""Save only user-created templates to file."""
try:
# Ensure directory exists
self.file_path.parent.mkdir(parents=True, exist_ok=True)
# Only persist non-default templates
templates_dict = {
template_id: template.to_dict()
for template_id, template in self._templates.items()
if not template.is_default
}
data = {
"version": "1.0.0",
"templates": templates_dict,
}
# Write to file
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"Failed to save templates to {self.file_path}: {e}")
raise
def get_all_templates(self) -> List[CaptureTemplate]:
"""Get all templates.
Returns:
List of all templates
"""
return list(self._templates.values())
def get_template(self, template_id: str) -> CaptureTemplate:
"""Get template by ID.
Args:
template_id: Template ID
Returns:
Template instance
Raises:
ValueError: If template not found
"""
if template_id not in self._templates:
raise ValueError(f"Template not found: {template_id}")
return self._templates[template_id]
def create_template(
self,
name: str,
engine_type: str,
engine_config: Dict[str, any],
description: Optional[str] = None,
) -> CaptureTemplate:
"""Create a new template.
Args:
name: Template name
engine_type: Engine type (mss, dxcam, wgc)
engine_config: Engine-specific configuration
description: Optional description
Returns:
Created template
Raises:
ValueError: If template with same name exists
"""
# Check for duplicate name
for template in self._templates.values():
if template.name == name and not template.is_default:
raise ValueError(f"Template with name '{name}' already exists")
# Generate new ID
template_id = f"tpl_{uuid.uuid4().hex[:8]}"
# Create template
now = datetime.utcnow()
template = CaptureTemplate(
id=template_id,
name=name,
engine_type=engine_type,
engine_config=engine_config,
is_default=False,
created_at=now,
updated_at=now,
description=description,
)
# Store and save
self._templates[template_id] = template
self._save()
logger.info(f"Created template: {name} ({template_id})")
return template
def update_template(
self,
template_id: str,
name: Optional[str] = None,
engine_config: Optional[Dict[str, any]] = None,
description: Optional[str] = None,
) -> CaptureTemplate:
"""Update an existing template.
Args:
template_id: Template ID
name: New name (optional)
engine_config: New engine config (optional)
description: New description (optional)
Returns:
Updated template
Raises:
ValueError: If template not found or is a default template
"""
if template_id not in self._templates:
raise ValueError(f"Template not found: {template_id}")
template = self._templates[template_id]
if template.is_default:
raise ValueError("Cannot modify default templates")
# Update fields
if name is not None:
template.name = name
if engine_config is not None:
template.engine_config = engine_config
if description is not None:
template.description = description
template.updated_at = datetime.utcnow()
# Save
self._save()
logger.info(f"Updated template: {template_id}")
return template
def delete_template(self, template_id: str) -> None:
"""Delete a template.
Args:
template_id: Template ID
Raises:
ValueError: If template not found or is a default template
"""
if template_id not in self._templates:
raise ValueError(f"Template not found: {template_id}")
template = self._templates[template_id]
if template.is_default:
raise ValueError("Cannot delete default templates")
# Remove and save
del self._templates[template_id]
self._save()
logger.info(f"Deleted template: {template_id}")