Add capture template system with in-memory defaults and split device settings UI
Some checks failed
Validate / validate (push) Failing after 8s
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>
This commit is contained in:
86
CLAUDE.md
Normal file
86
CLAUDE.md
Normal 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
|
||||
@@ -24,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
|
||||
@@ -37,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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">✕</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">✕</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">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveCaptureSettings()" title="Save">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Modal -->
|
||||
<div id="api-key-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 секунд)",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
|
||||
@@ -13,7 +14,11 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TemplateStore:
|
||||
"""Storage for capture templates."""
|
||||
"""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.
|
||||
@@ -23,13 +28,35 @@ class TemplateStore:
|
||||
"""
|
||||
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 templates from file."""
|
||||
"""Load user-created templates from file."""
|
||||
if not self.file_path.exists():
|
||||
logger.warning(f"Templates file not found: {self.file_path}")
|
||||
self._save() # Create empty file
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -37,33 +64,42 @@ class TemplateStore:
|
||||
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
|
||||
)
|
||||
|
||||
logger.info(f"Loaded {len(self._templates)} templates from storage")
|
||||
logger.info(f"Template store initialized with {len(self._templates)} templates")
|
||||
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 templates to file."""
|
||||
"""Save only user-created templates to file."""
|
||||
try:
|
||||
# Ensure directory exists
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Convert templates to dict format
|
||||
# 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 = {
|
||||
|
||||
Reference in New Issue
Block a user