Compare commits
4 Commits
3e835a8e4f
...
5370d80466
| Author | SHA1 | Date | |
|---|---|---|---|
| 5370d80466 | |||
| b5545d3198 | |||
| fdb73c9fc9 | |||
| fd38481e17 |
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
|
||||||
@@ -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.
|
**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:
|
#### When to restart:
|
||||||
- After modifying API routes (`api/routes.py`, `api/schemas.py`)
|
- After modifying API routes (`api/routes.py`, `api/schemas.py`)
|
||||||
- After updating core logic (`core/*.py`)
|
- After updating core logic (`core/*.py`)
|
||||||
@@ -22,12 +24,34 @@ netstat -an | grep 8080
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### How to restart:
|
#### How to restart:
|
||||||
1. Stop the current server (if running as background task, use TaskStop with the task ID)
|
1. **Find the task ID** of the running server (look for background bash tasks in conversation)
|
||||||
2. Start a new server instance:
|
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
|
```bash
|
||||||
cd server && python -m wled_controller.main
|
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:
|
#### Files that DON'T require restart:
|
||||||
- Static files (`static/*.html`, `static/*.css`, `static/*.js`) - these are served directly
|
- Static files (`static/*.html`, `static/*.css`, `static/*.js`) - these are served directly
|
||||||
@@ -35,24 +59,26 @@ netstat -an | grep 8080
|
|||||||
- Documentation files (`*.md`)
|
- Documentation files (`*.md`)
|
||||||
- Configuration files in `config/` if server supports hot-reload (check implementation)
|
- 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
|
#### 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
|
- 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", create a commit but DO NOT push
|
||||||
- If the user says "commit and push", you may push after committing
|
- 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
|
#### Workflow
|
||||||
|
|
||||||
1. Make changes to code
|
1. Make changes to code
|
||||||
2. Create commit when appropriate (with user consent)
|
2. **STOP and WAIT** - inform the user of changes and wait for instruction
|
||||||
3. **STOP and WAIT** - do not push
|
3. Only create commit when user explicitly requests it (e.g., "commit", "create a commit")
|
||||||
4. Only push when user explicitly requests it (e.g., "push", "commit and push", "push to remote")
|
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
|
### 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
|
3. Use `t('key')` function in `static/app.js` for dynamic content
|
||||||
4. No server restart needed (frontend only)
|
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()">×</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
|
## Authentication
|
||||||
|
|
||||||
Server uses API key authentication. Keys are configured in:
|
Server uses API key authentication. Keys are configured in:
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ dev = [
|
|||||||
"black>=24.0.0",
|
"black>=24.0.0",
|
||||||
"ruff>=0.6.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]
|
[project.urls]
|
||||||
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ async def create_device(
|
|||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: DeviceStore = Depends(get_device_store),
|
store: DeviceStore = Depends(get_device_store),
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
template_store: TemplateStore = Depends(get_template_store),
|
||||||
):
|
):
|
||||||
"""Create and attach a new WLED device."""
|
"""Create and attach a new WLED device."""
|
||||||
try:
|
try:
|
||||||
@@ -214,11 +215,29 @@ async def create_device(
|
|||||||
detail=f"Failed to connect to WLED device at {device_url}: {e}"
|
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)
|
# Create device in storage (LED count auto-detected from WLED)
|
||||||
device = store.create_device(
|
device = store.create_device(
|
||||||
name=device_data.name,
|
name=device_data.name,
|
||||||
url=device_data.url,
|
url=device_data.url,
|
||||||
led_count=wled_led_count,
|
led_count=wled_led_count,
|
||||||
|
capture_template_id=capture_template_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add to processor manager
|
# 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)
|
engine = EngineRegistry.create_engine(test_request.engine_type, test_request.engine_config)
|
||||||
|
|
||||||
# Run sustained capture test
|
# Run sustained capture test
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class DeviceCreate(BaseModel):
|
|||||||
|
|
||||||
name: str = Field(description="Device name", min_length=1, max_length=100)
|
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)")
|
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):
|
class DeviceUpdate(BaseModel):
|
||||||
@@ -63,6 +64,7 @@ class DeviceUpdate(BaseModel):
|
|||||||
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
|
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
|
||||||
url: Optional[str] = Field(None, description="WLED device URL")
|
url: Optional[str] = Field(None, description="WLED device URL")
|
||||||
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
|
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
|
||||||
|
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
|
||||||
|
|
||||||
|
|
||||||
class ColorCorrection(BaseModel):
|
class ColorCorrection(BaseModel):
|
||||||
@@ -153,6 +155,7 @@ class DeviceResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
settings: ProcessingSettings = Field(description="Processing settings")
|
settings: ProcessingSettings = Field(description="Processing settings")
|
||||||
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
|
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")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|
||||||
@@ -210,3 +213,110 @@ class ErrorResponse(BaseModel):
|
|||||||
message: str = Field(description="Error message")
|
message: str = Field(description="Error message")
|
||||||
detail: Optional[Dict] = Field(None, description="Additional error details")
|
detail: Optional[Dict] = Field(None, description="Additional error details")
|
||||||
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Error timestamp")
|
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."""
|
"""Storage configuration."""
|
||||||
|
|
||||||
devices_file: str = "data/devices.json"
|
devices_file: str = "data/devices.json"
|
||||||
|
templates_file: str = "data/capture_templates.json"
|
||||||
|
|
||||||
|
|
||||||
class LoggingConfig(BaseSettings):
|
class LoggingConfig(BaseSettings):
|
||||||
|
|||||||
@@ -168,12 +168,12 @@ class DXcamEngine(CaptureEngine):
|
|||||||
ScreenCapture object with image data
|
ScreenCapture object with image data
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If not initialized
|
|
||||||
ValueError: If display_index doesn't match configured output
|
ValueError: If display_index doesn't match configured output
|
||||||
RuntimeError: If capture fails
|
RuntimeError: If capture fails
|
||||||
"""
|
"""
|
||||||
|
# Auto-initialize if not already initialized
|
||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
raise RuntimeError("Engine not initialized")
|
self.initialize()
|
||||||
|
|
||||||
# DXcam is configured for a specific output
|
# DXcam is configured for a specific output
|
||||||
configured_output = self.config.get("output_idx", 0)
|
configured_output = self.config.get("output_idx", 0)
|
||||||
|
|||||||
@@ -115,12 +115,12 @@ class MSSEngine(CaptureEngine):
|
|||||||
ScreenCapture object with image data
|
ScreenCapture object with image data
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If not initialized
|
|
||||||
ValueError: If display_index is invalid
|
ValueError: If display_index is invalid
|
||||||
RuntimeError: If capture fails
|
RuntimeError: If capture fails
|
||||||
"""
|
"""
|
||||||
|
# Auto-initialize if not already initialized
|
||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
raise RuntimeError("Engine not initialized")
|
self.initialize()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# mss monitors[0] is the combined screen, monitors[1+] are individual displays
|
# mss monitors[0] is the combined screen, monitors[1+] are individual displays
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ from wled_controller.core.calibration import (
|
|||||||
PixelMapper,
|
PixelMapper,
|
||||||
create_default_calibration,
|
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.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.core.wled_client import WLEDClient
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
@@ -87,8 +88,10 @@ class ProcessorState:
|
|||||||
led_count: int
|
led_count: int
|
||||||
settings: ProcessingSettings
|
settings: ProcessingSettings
|
||||||
calibration: CalibrationConfig
|
calibration: CalibrationConfig
|
||||||
|
capture_template_id: str = "tpl_mss_default" # NEW: template ID for capture engine
|
||||||
wled_client: Optional[WLEDClient] = None
|
wled_client: Optional[WLEDClient] = None
|
||||||
pixel_mapper: Optional[PixelMapper] = None
|
pixel_mapper: Optional[PixelMapper] = None
|
||||||
|
capture_engine: Optional[CaptureEngine] = None # NEW: initialized capture engine
|
||||||
is_running: bool = False
|
is_running: bool = False
|
||||||
task: Optional[asyncio.Task] = None
|
task: Optional[asyncio.Task] = None
|
||||||
metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics)
|
metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics)
|
||||||
@@ -122,6 +125,7 @@ class ProcessorManager:
|
|||||||
led_count: int,
|
led_count: int,
|
||||||
settings: Optional[ProcessingSettings] = None,
|
settings: Optional[ProcessingSettings] = None,
|
||||||
calibration: Optional[CalibrationConfig] = None,
|
calibration: Optional[CalibrationConfig] = None,
|
||||||
|
capture_template_id: str = "tpl_mss_default",
|
||||||
):
|
):
|
||||||
"""Add a device for processing.
|
"""Add a device for processing.
|
||||||
|
|
||||||
@@ -131,6 +135,7 @@ class ProcessorManager:
|
|||||||
led_count: Number of LEDs
|
led_count: Number of LEDs
|
||||||
settings: Processing settings (uses defaults if None)
|
settings: Processing settings (uses defaults if None)
|
||||||
calibration: Calibration config (creates default if None)
|
calibration: Calibration config (creates default if None)
|
||||||
|
capture_template_id: Template ID for screen capture engine
|
||||||
"""
|
"""
|
||||||
if device_id in self._processors:
|
if device_id in self._processors:
|
||||||
raise ValueError(f"Device {device_id} already exists")
|
raise ValueError(f"Device {device_id} already exists")
|
||||||
@@ -147,6 +152,7 @@ class ProcessorManager:
|
|||||||
led_count=led_count,
|
led_count=led_count,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
|
capture_template_id=capture_template_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._processors[device_id] = state
|
self._processors[device_id] = state
|
||||||
@@ -270,6 +276,21 @@ class ProcessorManager:
|
|||||||
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
|
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
|
||||||
raise RuntimeError(f"Failed to connect to WLED device: {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
|
# Initialize pixel mapper
|
||||||
state.pixel_mapper = PixelMapper(
|
state.pixel_mapper = PixelMapper(
|
||||||
state.calibration,
|
state.calibration,
|
||||||
@@ -321,6 +342,11 @@ class ProcessorManager:
|
|||||||
await state.wled_client.close()
|
await state.wled_client.close()
|
||||||
state.wled_client = None
|
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}")
|
logger.info(f"Stopped processing for device {device_id}")
|
||||||
|
|
||||||
async def _processing_loop(self, device_id: str):
|
async def _processing_loop(self, device_id: str):
|
||||||
@@ -351,8 +377,11 @@ class ProcessorManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Run blocking operations in thread pool to avoid blocking event loop
|
# Run blocking operations in thread pool to avoid blocking event loop
|
||||||
# Capture screen (blocking I/O)
|
# Capture screen using engine (blocking I/O)
|
||||||
capture = await asyncio.to_thread(capture_display, settings.display_index)
|
capture = await asyncio.to_thread(
|
||||||
|
state.capture_engine.capture_display,
|
||||||
|
settings.display_index
|
||||||
|
)
|
||||||
|
|
||||||
# Extract border pixels (CPU-intensive)
|
# Extract border pixels (CPU-intensive)
|
||||||
border_pixels = await asyncio.to_thread(extract_border_pixels, capture, settings.border_width)
|
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,
|
"wled_led_type": h.wled_led_type,
|
||||||
"error": h.error,
|
"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.config import get_config
|
||||||
from wled_controller.core.processor_manager import ProcessorManager
|
from wled_controller.core.processor_manager import ProcessorManager
|
||||||
from wled_controller.storage import DeviceStore
|
from wled_controller.storage import DeviceStore
|
||||||
|
from wled_controller.storage.template_store import TemplateStore
|
||||||
from wled_controller.utils import setup_logging, get_logger
|
from wled_controller.utils import setup_logging, get_logger
|
||||||
|
|
||||||
# Initialize logging
|
# Initialize logging
|
||||||
@@ -26,6 +27,7 @@ config = get_config()
|
|||||||
|
|
||||||
# Initialize storage and processing
|
# Initialize storage and processing
|
||||||
device_store = DeviceStore(config.storage.devices_file)
|
device_store = DeviceStore(config.storage.devices_file)
|
||||||
|
template_store = TemplateStore(config.storage.templates_file)
|
||||||
processor_manager = ProcessorManager()
|
processor_manager = ProcessorManager()
|
||||||
|
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("All API requests require valid Bearer token authentication")
|
logger.info("All API requests require valid Bearer token authentication")
|
||||||
|
|
||||||
# Initialize API dependencies
|
# Initialize API dependencies
|
||||||
init_dependencies(device_store, processor_manager)
|
init_dependencies(device_store, template_store, processor_manager)
|
||||||
|
|
||||||
# Load existing devices into processor manager
|
# Load existing devices into processor manager
|
||||||
devices = device_store.get_all_devices()
|
devices = device_store.get_all_devices()
|
||||||
@@ -71,6 +73,7 @@ async def lifespan(app: FastAPI):
|
|||||||
led_count=device.led_count,
|
led_count=device.led_count,
|
||||||
settings=device.settings,
|
settings=device.settings,
|
||||||
calibration=device.calibration,
|
calibration=device.calibration,
|
||||||
|
capture_template_id=device.capture_template_id,
|
||||||
)
|
)
|
||||||
logger.info(f"Loaded device: {device.name} ({device.id})")
|
logger.info(f"Loaded device: {device.name} ({device.id})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -165,5 +168,5 @@ if __name__ == "__main__":
|
|||||||
host=config.server.host,
|
host=config.server.host,
|
||||||
port=config.server.port,
|
port=config.server.port,
|
||||||
log_level=config.server.log_level.lower(),
|
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 class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
|
||||||
⚙️
|
⚙️
|
||||||
</button>
|
</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 class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
|
||||||
📐
|
📐
|
||||||
</button>
|
</button>
|
||||||
@@ -737,12 +740,7 @@ async function removeDevice(deviceId) {
|
|||||||
|
|
||||||
async function showSettings(deviceId) {
|
async function showSettings(deviceId) {
|
||||||
try {
|
try {
|
||||||
// Fetch device data, displays, and templates in parallel
|
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() });
|
||||||
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) {
|
if (deviceResponse.status === 401) {
|
||||||
handle401Error();
|
handle401Error();
|
||||||
@@ -756,48 +754,7 @@ async function showSettings(deviceId) {
|
|||||||
|
|
||||||
const device = await deviceResponse.json();
|
const device = await deviceResponse.json();
|
||||||
|
|
||||||
// Populate display index select
|
// Populate fields
|
||||||
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
|
|
||||||
document.getElementById('settings-device-id').value = device.id;
|
document.getElementById('settings-device-id').value = device.id;
|
||||||
document.getElementById('settings-device-name').value = device.name;
|
document.getElementById('settings-device-name').value = device.name;
|
||||||
document.getElementById('settings-device-url').value = device.url;
|
document.getElementById('settings-device-url').value = device.url;
|
||||||
@@ -807,9 +764,7 @@ async function showSettings(deviceId) {
|
|||||||
settingsInitialValues = {
|
settingsInitialValues = {
|
||||||
name: device.name,
|
name: device.name,
|
||||||
url: device.url,
|
url: device.url,
|
||||||
display_index: String(device.settings.display_index ?? 0),
|
|
||||||
state_check_interval: String(device.settings.state_check_interval || 30),
|
state_check_interval: String(device.settings.state_check_interval || 30),
|
||||||
capture_template_id: device.capture_template_id || 'tpl_mss_default',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show modal
|
// Show modal
|
||||||
@@ -832,9 +787,7 @@ function isSettingsDirty() {
|
|||||||
return (
|
return (
|
||||||
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
|
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
|
||||||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
|
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
|
||||||
document.getElementById('settings-display-index').value !== settingsInitialValues.display_index ||
|
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
|
||||||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ||
|
|
||||||
document.getElementById('settings-capture-template').value !== settingsInitialValues.capture_template_id
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -859,9 +812,7 @@ async function saveDeviceSettings() {
|
|||||||
const deviceId = document.getElementById('settings-device-id').value;
|
const deviceId = document.getElementById('settings-device-id').value;
|
||||||
const name = document.getElementById('settings-device-name').value.trim();
|
const name = document.getElementById('settings-device-name').value.trim();
|
||||||
const url = document.getElementById('settings-device-url').value.trim();
|
const url = document.getElementById('settings-device-url').value.trim();
|
||||||
const display_index = parseInt(document.getElementById('settings-display-index').value) || 0;
|
|
||||||
const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
|
const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
|
||||||
const capture_template_id = document.getElementById('settings-capture-template').value;
|
|
||||||
const error = document.getElementById('settings-error');
|
const error = document.getElementById('settings-error');
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
@@ -872,11 +823,11 @@ async function saveDeviceSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update device info (name, url, capture_template_id)
|
// Update device info (name, url)
|
||||||
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
body: JSON.stringify({ name, url, capture_template_id })
|
body: JSON.stringify({ name, url })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (deviceResponse.status === 401) {
|
if (deviceResponse.status === 401) {
|
||||||
@@ -895,7 +846,7 @@ async function saveDeviceSettings() {
|
|||||||
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
body: JSON.stringify({ display_index, state_check_interval })
|
body: JSON.stringify({ state_check_interval })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (settingsResponse.status === 401) {
|
if (settingsResponse.status === 401) {
|
||||||
@@ -904,7 +855,7 @@ async function saveDeviceSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (settingsResponse.ok) {
|
if (settingsResponse.ok) {
|
||||||
showToast('Device settings updated', 'success');
|
showToast(t('settings.saved'), 'success');
|
||||||
forceCloseDeviceSettingsModal();
|
forceCloseDeviceSettingsModal();
|
||||||
loadDevices();
|
loadDevices();
|
||||||
} else {
|
} 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
|
// Card brightness controls
|
||||||
function updateBrightnessLabel(deviceId, value) {
|
function updateBrightnessLabel(deviceId, value) {
|
||||||
const slider = document.querySelector(`[data-device-id="${deviceId}"] .brightness-slider`);
|
const slider = document.querySelector(`[data-device-id="${deviceId}"] .brightness-slider`);
|
||||||
@@ -971,10 +1086,16 @@ async function handleAddDevice(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const body = { name, url };
|
||||||
|
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
||||||
|
if (lastTemplateId) {
|
||||||
|
body.capture_template_id = lastTemplateId;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/devices`, {
|
const response = await fetch(`${API_BASE}/devices`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
body: JSON.stringify({ name, url })
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -1928,12 +2049,18 @@ document.addEventListener('click', (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings modal: dirty check
|
// General settings modal: dirty check
|
||||||
if (modalId === 'device-settings-modal') {
|
if (modalId === 'device-settings-modal') {
|
||||||
closeDeviceSettingsModal();
|
closeDeviceSettingsModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture settings modal: dirty check
|
||||||
|
if (modalId === 'capture-settings-modal') {
|
||||||
|
closeCaptureSettingsModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Calibration modal: dirty check
|
// Calibration modal: dirty check
|
||||||
if (modalId === 'calibration-modal') {
|
if (modalId === 'calibration-modal') {
|
||||||
closeCalibrationModal();
|
closeCalibrationModal();
|
||||||
@@ -1977,8 +2104,9 @@ const deviceTutorialSteps = [
|
|||||||
{ selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' },
|
{ 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(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(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(3)', textKey: 'device.tip.capture_settings', position: 'top' },
|
||||||
{ selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.webui', 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) {
|
function startTutorial(config) {
|
||||||
@@ -2218,7 +2346,10 @@ function renderTemplatesList(templates) {
|
|||||||
return;
|
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 engineIcon = getEngineIcon(template.engine_type);
|
||||||
const defaultBadge = template.is_default
|
const defaultBadge = template.is_default
|
||||||
? `<span class="badge badge-default">${t('templates.default')}</span>`
|
? `<span class="badge badge-default">${t('templates.default')}</span>`
|
||||||
@@ -2258,10 +2389,21 @@ function renderTemplatesList(templates) {
|
|||||||
</div>
|
</div>
|
||||||
</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-icon">+</div>
|
||||||
<div class="add-template-label">${t('templates.add')}</div>
|
<div class="add-template-label">${t('templates.add')}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get engine icon
|
// Get engine icon
|
||||||
@@ -2281,7 +2423,16 @@ async function showAddTemplateModal() {
|
|||||||
// Load available engines
|
// Load available engines
|
||||||
await loadAvailableEngines();
|
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
|
// Edit template
|
||||||
@@ -2315,7 +2466,16 @@ async function editTemplate(templateId) {
|
|||||||
document.getElementById('template-test-results').style.display = 'none';
|
document.getElementById('template-test-results').style.display = 'none';
|
||||||
document.getElementById('template-error').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) {
|
} catch (error) {
|
||||||
console.error('Error loading template:', error);
|
console.error('Error loading template:', error);
|
||||||
showToast(t('templates.error.load') + ': ' + error.message, 'error');
|
showToast(t('templates.error.load') + ': ' + error.message, 'error');
|
||||||
|
|||||||
@@ -206,11 +206,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Device Settings Modal -->
|
<!-- General Settings Modal -->
|
||||||
<div id="device-settings-modal" class="modal">
|
<div id="device-settings-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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>
|
<button class="modal-close-btn" onclick="closeDeviceSettingsModal()" title="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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>
|
<small class="input-hint" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="settings-display-index" data-i18n="settings.display_index">Display:</label>
|
|
||||||
<select id="settings-display-index"></select>
|
|
||||||
<small class="input-hint" data-i18n="settings.display_index.hint">Which screen to capture for this device</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<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">
|
<div class="form-group">
|
||||||
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
|
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
|
||||||
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
|
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
|
||||||
@@ -256,6 +244,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Login Modal -->
|
||||||
<div id="api-key-modal" class="modal">
|
<div id="api-key-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
|
|||||||
@@ -32,6 +32,61 @@
|
|||||||
"displays.loading": "Loading displays...",
|
"displays.loading": "Loading displays...",
|
||||||
"displays.none": "No displays available",
|
"displays.none": "No displays available",
|
||||||
"displays.failed": "Failed to load displays",
|
"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.title": "\uD83D\uDCA1 Devices",
|
||||||
"devices.add": "Add New Device",
|
"devices.add": "Add New Device",
|
||||||
"devices.loading": "Loading devices...",
|
"devices.loading": "Loading devices...",
|
||||||
@@ -54,7 +109,8 @@
|
|||||||
"device.button.add": "Add Device",
|
"device.button.add": "Add Device",
|
||||||
"device.button.start": "Start",
|
"device.button.start": "Start",
|
||||||
"device.button.stop": "Stop",
|
"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.calibrate": "Calibrate",
|
||||||
"device.button.remove": "Remove",
|
"device.button.remove": "Remove",
|
||||||
"device.button.webui": "Open WLED Web UI",
|
"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.metadata": "Device info (LED count, type, color channels) is auto-detected from WLED",
|
||||||
"device.tip.brightness": "Slide to adjust device brightness",
|
"device.tip.brightness": "Slide to adjust device brightness",
|
||||||
"device.tip.start": "Start or stop screen capture processing",
|
"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.calibrate": "Calibrate LED positions, direction, and coverage",
|
||||||
"device.tip.webui": "Open WLED's built-in web interface for advanced configuration",
|
"device.tip.webui": "Open WLED's built-in web interface for advanced configuration",
|
||||||
"device.tip.add": "Click here to add a new WLED device",
|
"device.tip.add": "Click here to add a new WLED device",
|
||||||
"settings.title": "Device Settings",
|
"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": "Brightness:",
|
||||||
"settings.brightness.hint": "Global brightness for this WLED device (0-100%)",
|
"settings.brightness.hint": "Global brightness for this WLED device (0-100%)",
|
||||||
"settings.url.hint": "IP address or hostname of your WLED device",
|
"settings.url.hint": "IP address or hostname of your WLED device",
|
||||||
"settings.display_index": "Display:",
|
"settings.display_index": "Display:",
|
||||||
"settings.display_index.hint": "Which screen to capture for this device",
|
"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.button.cancel": "Cancel",
|
||||||
"settings.health_interval": "Health Check Interval (s):",
|
"settings.health_interval": "Health Check Interval (s):",
|
||||||
"settings.health_interval.hint": "How often to check the WLED device status (5-600 seconds)",
|
"settings.health_interval.hint": "How often to check the WLED device status (5-600 seconds)",
|
||||||
|
|||||||
@@ -32,6 +32,61 @@
|
|||||||
"displays.loading": "Загрузка дисплеев...",
|
"displays.loading": "Загрузка дисплеев...",
|
||||||
"displays.none": "Нет доступных дисплеев",
|
"displays.none": "Нет доступных дисплеев",
|
||||||
"displays.failed": "Не удалось загрузить дисплеи",
|
"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.title": "\uD83D\uDCA1 Устройства",
|
||||||
"devices.add": "Добавить Новое Устройство",
|
"devices.add": "Добавить Новое Устройство",
|
||||||
"devices.loading": "Загрузка устройств...",
|
"devices.loading": "Загрузка устройств...",
|
||||||
@@ -54,7 +109,8 @@
|
|||||||
"device.button.add": "Добавить Устройство",
|
"device.button.add": "Добавить Устройство",
|
||||||
"device.button.start": "Запустить",
|
"device.button.start": "Запустить",
|
||||||
"device.button.stop": "Остановить",
|
"device.button.stop": "Остановить",
|
||||||
"device.button.settings": "Настройки",
|
"device.button.settings": "Основные настройки",
|
||||||
|
"device.button.capture_settings": "Настройки захвата",
|
||||||
"device.button.calibrate": "Калибровка",
|
"device.button.calibrate": "Калибровка",
|
||||||
"device.button.remove": "Удалить",
|
"device.button.remove": "Удалить",
|
||||||
"device.button.webui": "Открыть веб-интерфейс WLED",
|
"device.button.webui": "Открыть веб-интерфейс WLED",
|
||||||
@@ -81,16 +137,23 @@
|
|||||||
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически из WLED",
|
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически из WLED",
|
||||||
"device.tip.brightness": "Перетащите для регулировки яркости",
|
"device.tip.brightness": "Перетащите для регулировки яркости",
|
||||||
"device.tip.start": "Запуск или остановка захвата экрана",
|
"device.tip.start": "Запуск или остановка захвата экрана",
|
||||||
"device.tip.settings": "Настройки устройства (дисплей, FPS, интервал проверки)",
|
"device.tip.settings": "Основные настройки устройства (имя, URL, интервал проверки)",
|
||||||
|
"device.tip.capture_settings": "Настройки захвата (дисплей, шаблон захвата)",
|
||||||
"device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия",
|
"device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия",
|
||||||
"device.tip.webui": "Открыть встроенный веб-интерфейс WLED для расширенной настройки",
|
"device.tip.webui": "Открыть встроенный веб-интерфейс WLED для расширенной настройки",
|
||||||
"device.tip.add": "Нажмите, чтобы добавить новое WLED устройство",
|
"device.tip.add": "Нажмите, чтобы добавить новое WLED устройство",
|
||||||
"settings.title": "Настройки Устройства",
|
"settings.title": "Настройки Устройства",
|
||||||
|
"settings.general.title": "Основные Настройки",
|
||||||
|
"settings.capture.title": "Настройки Захвата",
|
||||||
|
"settings.capture.saved": "Настройки захвата обновлены",
|
||||||
|
"settings.capture.failed": "Не удалось сохранить настройки захвата",
|
||||||
"settings.brightness": "Яркость:",
|
"settings.brightness": "Яркость:",
|
||||||
"settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)",
|
"settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)",
|
||||||
"settings.url.hint": "IP адрес или имя хоста вашего WLED устройства",
|
"settings.url.hint": "IP адрес или имя хоста вашего WLED устройства",
|
||||||
"settings.display_index": "Дисплей:",
|
"settings.display_index": "Дисплей:",
|
||||||
"settings.display_index.hint": "Какой экран захватывать для этого устройства",
|
"settings.display_index.hint": "Какой экран захватывать для этого устройства",
|
||||||
|
"settings.capture_template": "Шаблон Захвата:",
|
||||||
|
"settings.capture_template.hint": "Движок захвата экрана и конфигурация для этого устройства",
|
||||||
"settings.button.cancel": "Отмена",
|
"settings.button.cancel": "Отмена",
|
||||||
"settings.health_interval": "Интервал Проверки (с):",
|
"settings.health_interval": "Интервал Проверки (с):",
|
||||||
"settings.health_interval.hint": "Как часто проверять статус WLED устройства (5-600 секунд)",
|
"settings.health_interval.hint": "Как часто проверять статус WLED устройства (5-600 секунд)",
|
||||||
|
|||||||
@@ -1711,6 +1711,24 @@ input:-webkit-autofill:focus {
|
|||||||
gap: 20px;
|
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 {
|
.template-card {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class Device:
|
|||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
settings: Optional[ProcessingSettings] = None,
|
settings: Optional[ProcessingSettings] = None,
|
||||||
calibration: Optional[CalibrationConfig] = None,
|
calibration: Optional[CalibrationConfig] = None,
|
||||||
|
capture_template_id: str = "tpl_mss_default",
|
||||||
created_at: Optional[datetime] = None,
|
created_at: Optional[datetime] = None,
|
||||||
updated_at: Optional[datetime] = None,
|
updated_at: Optional[datetime] = None,
|
||||||
):
|
):
|
||||||
@@ -43,6 +44,7 @@ class Device:
|
|||||||
enabled: Whether device is enabled
|
enabled: Whether device is enabled
|
||||||
settings: Processing settings
|
settings: Processing settings
|
||||||
calibration: Calibration configuration
|
calibration: Calibration configuration
|
||||||
|
capture_template_id: ID of assigned capture template
|
||||||
created_at: Creation timestamp
|
created_at: Creation timestamp
|
||||||
updated_at: Last update timestamp
|
updated_at: Last update timestamp
|
||||||
"""
|
"""
|
||||||
@@ -53,6 +55,7 @@ class Device:
|
|||||||
self.enabled = enabled
|
self.enabled = enabled
|
||||||
self.settings = settings or ProcessingSettings()
|
self.settings = settings or ProcessingSettings()
|
||||||
self.calibration = calibration or create_default_calibration(led_count)
|
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.created_at = created_at or datetime.utcnow()
|
||||||
self.updated_at = updated_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,
|
"state_check_interval": self.settings.state_check_interval,
|
||||||
},
|
},
|
||||||
"calibration": calibration_to_dict(self.calibration),
|
"calibration": calibration_to_dict(self.calibration),
|
||||||
|
"capture_template_id": self.capture_template_id,
|
||||||
"created_at": self.created_at.isoformat(),
|
"created_at": self.created_at.isoformat(),
|
||||||
"updated_at": self.updated_at.isoformat(),
|
"updated_at": self.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
@@ -117,6 +121,12 @@ class Device:
|
|||||||
else create_default_calibration(data["led_count"])
|
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(
|
return cls(
|
||||||
device_id=data["id"],
|
device_id=data["id"],
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
@@ -125,6 +135,7 @@ class Device:
|
|||||||
enabled=data.get("enabled", True),
|
enabled=data.get("enabled", True),
|
||||||
settings=settings,
|
settings=settings,
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
|
capture_template_id=capture_template_id,
|
||||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||||
)
|
)
|
||||||
@@ -206,6 +217,7 @@ class DeviceStore:
|
|||||||
led_count: int,
|
led_count: int,
|
||||||
settings: Optional[ProcessingSettings] = None,
|
settings: Optional[ProcessingSettings] = None,
|
||||||
calibration: Optional[CalibrationConfig] = None,
|
calibration: Optional[CalibrationConfig] = None,
|
||||||
|
capture_template_id: str = "tpl_mss_default",
|
||||||
) -> Device:
|
) -> Device:
|
||||||
"""Create a new device.
|
"""Create a new device.
|
||||||
|
|
||||||
@@ -215,6 +227,7 @@ class DeviceStore:
|
|||||||
led_count: Number of LEDs
|
led_count: Number of LEDs
|
||||||
settings: Processing settings
|
settings: Processing settings
|
||||||
calibration: Calibration configuration
|
calibration: Calibration configuration
|
||||||
|
capture_template_id: ID of assigned capture template
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created device
|
Created device
|
||||||
@@ -233,6 +246,7 @@ class DeviceStore:
|
|||||||
led_count=led_count,
|
led_count=led_count,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
|
capture_template_id=capture_template_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store
|
# Store
|
||||||
@@ -270,6 +284,7 @@ class DeviceStore:
|
|||||||
enabled: Optional[bool] = None,
|
enabled: Optional[bool] = None,
|
||||||
settings: Optional[ProcessingSettings] = None,
|
settings: Optional[ProcessingSettings] = None,
|
||||||
calibration: Optional[CalibrationConfig] = None,
|
calibration: Optional[CalibrationConfig] = None,
|
||||||
|
capture_template_id: Optional[str] = None,
|
||||||
) -> Device:
|
) -> Device:
|
||||||
"""Update device.
|
"""Update device.
|
||||||
|
|
||||||
@@ -281,6 +296,7 @@ class DeviceStore:
|
|||||||
enabled: New enabled state (optional)
|
enabled: New enabled state (optional)
|
||||||
settings: New settings (optional)
|
settings: New settings (optional)
|
||||||
calibration: New calibration (optional)
|
calibration: New calibration (optional)
|
||||||
|
capture_template_id: New capture template ID (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated device
|
Updated device
|
||||||
@@ -313,6 +329,8 @@ class DeviceStore:
|
|||||||
f"does not match device LED count ({device.led_count})"
|
f"does not match device LED count ({device.led_count})"
|
||||||
)
|
)
|
||||||
device.calibration = calibration
|
device.calibration = calibration
|
||||||
|
if capture_template_id is not None:
|
||||||
|
device.capture_template_id = capture_template_id
|
||||||
|
|
||||||
device.updated_at = datetime.utcnow()
|
device.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
|||||||
61
server/src/wled_controller/storage/template.py
Normal file
61
server/src/wled_controller/storage/template.py
Normal 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"),
|
||||||
|
)
|
||||||
257
server/src/wled_controller/storage/template_store.py
Normal file
257
server/src/wled_controller/storage/template_store.py
Normal 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}")
|
||||||
Reference in New Issue
Block a user