Compare commits
2 Commits
bafd8b4130
...
f8656b72a6
| Author | SHA1 | Date | |
|---|---|---|---|
| f8656b72a6 | |||
| 9cfe628cc5 |
54
CODEBASE_REVIEW.md
Normal file
54
CODEBASE_REVIEW.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Codebase Review — 2026-02-26
|
||||||
|
|
||||||
|
Findings from full codebase review. Items ordered by priority within each category.
|
||||||
|
|
||||||
|
## Stability (Critical)
|
||||||
|
|
||||||
|
- [x] **Fatal loop exception leaks resources** — Added outer `try/except/finally` with `self._running = False` to all 10 processing loop methods across `live_stream.py`, `color_strip_stream.py`, `effect_stream.py`, `audio_stream.py`, `composite_stream.py`, `mapped_stream.py`. Also added per-iteration `try/except` where missing.
|
||||||
|
- [x] **`_is_running` flag cleanup** — Fixed via `finally: self._running = False` in all loop methods. *(Race condition via `threading.Event` deferred — current pattern sufficient with the finally block.)*
|
||||||
|
- [x] **`ColorStripStreamManager` thread safety** — **FALSE POSITIVE**: All access is from the async event loop; methods are synchronous with no `await` points, so no preemption is possible.
|
||||||
|
- [x] **Audio `stream.stop()` called under lock** — Moved `stream.stop()` outside lock scope in both `release()` and `release_all()` in `audio_capture.py`.
|
||||||
|
- [x] **WS accept-before-validate** — **FALSE POSITIVE**: All WS endpoints validate auth and resolve configs BEFORE calling `websocket.accept()`.
|
||||||
|
- [x] **Capture error no-backoff** — Added consecutive error counter with exponential backoff (`min(1.0, 0.1 * (errors - 5))`) in `ScreenCaptureLiveStream._capture_loop()`.
|
||||||
|
- [ ] **WGC session close not detected** — Deferred (Windows-specific edge case, low priority).
|
||||||
|
- [x] **`LiveStreamManager.acquire()` not thread-safe** — **FALSE POSITIVE**: Same as ColorStripStreamManager — all access from async event loop, no await in methods.
|
||||||
|
|
||||||
|
## Performance (High Impact)
|
||||||
|
|
||||||
|
- [x] **Per-pixel Python loop in `send_pixels()`** — Replaced per-pixel Python loop with `np.array().tobytes()` in `ddp_client.py`. Hot path already uses `send_pixels_numpy()`.
|
||||||
|
- [ ] **WGC 6MB frame allocation per callback** — Deferred (Windows-specific, requires WGC API changes).
|
||||||
|
- [x] **Gradient rendering O(LEDs×Stops) Python loop** — Vectorized with NumPy: `np.searchsorted` for stop lookup + vectorized interpolation in `_compute_gradient_colors()`.
|
||||||
|
- [x] **`PixelateFilter` nested Python loop** — Replaced with `cv2.resize` down (INTER_AREA) + up (INTER_NEAREST) — pure C++ backend.
|
||||||
|
- [x] **`DownscalerFilter` double allocation** — **FALSE POSITIVE**: Already uses single `cv2.resize()` call (vectorized C++).
|
||||||
|
- [x] **`SaturationFilter` ~25MB temp arrays** — **FALSE POSITIVE**: Already uses pre-allocated scratch buffer and vectorized in-place numpy.
|
||||||
|
- [x] **`FrameInterpolationFilter` copies full image** — **FALSE POSITIVE**: Already uses vectorized numpy integer blending with image pool.
|
||||||
|
- [x] **`datetime.utcnow()` per frame** — **LOW IMPACT**: ~1-2μs per call, negligible at 60fps. Deprecation tracked under Backend Quality.
|
||||||
|
- [x] **Unbounded diagnostic lists** — **FALSE POSITIVE**: Lists are cleared every 5 seconds (~300 entries max at 60fps). Trivial memory.
|
||||||
|
|
||||||
|
## Frontend Quality
|
||||||
|
|
||||||
|
- [x] **`lockBody()`/`unlockBody()` not re-entrant** — Added `_lockCount` reference counter and `_savedScrollY` in `ui.js`. First lock saves scroll, last unlock restores.
|
||||||
|
- [x] **XSS via unescaped engine config keys** — **FALSE POSITIVE**: Both capture template and audio template card renderers already use `escapeHtml()` on keys and values.
|
||||||
|
- [x] **LED preview WS `onclose` not nulled** — Added `ws.onclose = null` before `ws.close()` in `disconnectLedPreviewWS()` in `targets.js`.
|
||||||
|
- [x] **`fetchWithAuth` retry adds duplicate listeners** — Added `{ once: true }` to abort signal listener in `api.js`.
|
||||||
|
- [x] **Audio `requestAnimationFrame` loop continues after WS close** — **FALSE POSITIVE**: Loop already checks `testAudioModal.isOpen` before scheduling next frame, and `_cleanupTest()` cancels the animation frame.
|
||||||
|
|
||||||
|
## Backend Quality
|
||||||
|
|
||||||
|
- [ ] **No thread-safety in `JsonStore`** — Deferred (low risk — all stores are accessed from async event loop).
|
||||||
|
- [x] **Auth token prefix logged** — Removed token prefix from log message in `auth.py`. Now logs only "Invalid API key attempt".
|
||||||
|
- [ ] **Duplicate capture/test code** — Deferred (code duplication, not a bug — refactoring would reduce LOC but doesn't fix a defect).
|
||||||
|
- [x] **Update methods allow duplicate names** — Added name uniqueness checks to `update_template` in `template_store.py`, `postprocessing_template_store.py`, `audio_template_store.py`, `pattern_template_store.py`, and `update_profile` in `profile_store.py`. Also added missing check to `create_profile`.
|
||||||
|
- [ ] **Routes access `manager._private` attrs** — Deferred (stylistic, not a bug — would require adding public accessor methods).
|
||||||
|
- [x] **Non-atomic file writes** — Created `utils/file_ops.py` with `atomic_write_json()` helper (tempfile + `os.replace`). Updated all 10 store files.
|
||||||
|
- [ ] **444 f-string logger calls** — Deferred (performance impact negligible — Python evaluates f-strings very fast; lazy `%s` formatting only matters at very high call rates).
|
||||||
|
- [x] **`get_source()` silent bug** — Fixed: `color_strip_sources.py:_resolve_display_index()` called `picture_source_store.get_source()` which doesn't exist (should be `get_stream()`). Was silently returning `0` for display index.
|
||||||
|
- [ ] **`get_config()` race** — Deferred (low risk — config changes are infrequent user-initiated operations).
|
||||||
|
- [ ] **`datetime.utcnow()` deprecated** — Deferred (functional, deprecation warning only appears in Python 3.12+).
|
||||||
|
- [x] **Inconsistent DELETE status codes** — Changed `audio_sources.py` and `value_sources.py` DELETE endpoints from 200 to 204 (matching all other DELETE endpoints).
|
||||||
|
|
||||||
|
## Architecture (Observations, no action needed)
|
||||||
|
|
||||||
|
**Strengths**: Clean layered design, plugin registries, reference-counted stream sharing, consistent API patterns.
|
||||||
|
|
||||||
|
**Weaknesses**: No backpressure (slow consumers buffer frames), thread count grows linearly, config global singleton, reference counting races.
|
||||||
@@ -59,7 +59,7 @@ def verify_api_key(
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not authenticated_as:
|
if not authenticated_as:
|
||||||
logger.warning(f"Invalid API key attempt: {token[:8]}...")
|
logger.warning("Invalid API key attempt")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid API key",
|
detail="Invalid API key",
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ async def stream_capture_test(
|
|||||||
done_event.set()
|
done_event.set()
|
||||||
|
|
||||||
# Start capture in background thread
|
# Start capture in background thread
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
capture_future = loop.run_in_executor(None, _capture_loop)
|
capture_future = loop.run_in_executor(None, _capture_loop)
|
||||||
|
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
@@ -142,6 +142,8 @@ async def stream_capture_test(
|
|||||||
|
|
||||||
# Check for init error
|
# Check for init error
|
||||||
if init_error:
|
if init_error:
|
||||||
|
stop_event.set()
|
||||||
|
await capture_future
|
||||||
await websocket.send_json({"type": "error", "detail": init_error})
|
await websocket.send_json({"type": "error", "detail": init_error})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ async def update_audio_source(
|
|||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v1/audio-sources/{source_id}", tags=["Audio Sources"])
|
@router.delete("/api/v1/audio-sources/{source_id}", status_code=204, tags=["Audio Sources"])
|
||||||
async def delete_audio_source(
|
async def delete_audio_source(
|
||||||
source_id: str,
|
source_id: str,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
@@ -143,7 +143,6 @@ async def delete_audio_source(
|
|||||||
)
|
)
|
||||||
|
|
||||||
store.delete_source(source_id)
|
store.delete_source(source_id)
|
||||||
return {"status": "deleted", "id": source_id}
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ def _resolve_display_index(picture_source_id: str, picture_source_store: Picture
|
|||||||
if not picture_source_id or depth > 5:
|
if not picture_source_id or depth > 5:
|
||||||
return 0
|
return 0
|
||||||
try:
|
try:
|
||||||
ps = picture_source_store.get_source(picture_source_id)
|
ps = picture_source_store.get_stream(picture_source_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
return 0
|
return 0
|
||||||
if isinstance(ps, ScreenCapturePictureSource):
|
if isinstance(ps, ScreenCapturePictureSource):
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
"""System routes: health, version, displays, performance, ADB."""
|
"""System routes: health, version, displays, performance, backup/restore, ADB."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from wled_controller import __version__
|
from wled_controller import __version__
|
||||||
@@ -19,10 +24,12 @@ from wled_controller.api.schemas.system import (
|
|||||||
HealthResponse,
|
HealthResponse,
|
||||||
PerformanceResponse,
|
PerformanceResponse,
|
||||||
ProcessListResponse,
|
ProcessListResponse,
|
||||||
|
RestoreResponse,
|
||||||
VersionResponse,
|
VersionResponse,
|
||||||
)
|
)
|
||||||
|
from wled_controller.config import get_config
|
||||||
from wled_controller.core.capture.screen_capture import get_available_displays
|
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import atomic_write_json, get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -206,6 +213,141 @@ async def get_metrics_history(
|
|||||||
return manager.metrics_history.get_history()
|
return manager.metrics_history.get_history()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration backup / restore
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Mapping: logical store name → StorageConfig attribute name
|
||||||
|
STORE_MAP = {
|
||||||
|
"devices": "devices_file",
|
||||||
|
"capture_templates": "templates_file",
|
||||||
|
"postprocessing_templates": "postprocessing_templates_file",
|
||||||
|
"picture_sources": "picture_sources_file",
|
||||||
|
"picture_targets": "picture_targets_file",
|
||||||
|
"pattern_templates": "pattern_templates_file",
|
||||||
|
"color_strip_sources": "color_strip_sources_file",
|
||||||
|
"audio_sources": "audio_sources_file",
|
||||||
|
"audio_templates": "audio_templates_file",
|
||||||
|
"value_sources": "value_sources_file",
|
||||||
|
"profiles": "profiles_file",
|
||||||
|
}
|
||||||
|
|
||||||
|
_RESTART_SCRIPT = Path(__file__).resolve().parents[4] / "restart.ps1"
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_restart() -> None:
|
||||||
|
"""Spawn restart.ps1 after a short delay so the HTTP response completes."""
|
||||||
|
|
||||||
|
def _restart():
|
||||||
|
import time
|
||||||
|
time.sleep(1)
|
||||||
|
subprocess.Popen(
|
||||||
|
["powershell", "-ExecutionPolicy", "Bypass", "-File", str(_RESTART_SCRIPT)],
|
||||||
|
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||||
|
)
|
||||||
|
|
||||||
|
threading.Thread(target=_restart, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/system/backup", tags=["System"])
|
||||||
|
def backup_config(_: AuthRequired):
|
||||||
|
"""Download all configuration as a single JSON backup file."""
|
||||||
|
config = get_config()
|
||||||
|
stores = {}
|
||||||
|
for store_key, config_attr in STORE_MAP.items():
|
||||||
|
file_path = Path(getattr(config.storage, config_attr))
|
||||||
|
if file_path.exists():
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
|
stores[store_key] = json.load(f)
|
||||||
|
else:
|
||||||
|
stores[store_key] = {}
|
||||||
|
|
||||||
|
backup = {
|
||||||
|
"meta": {
|
||||||
|
"format": "ledgrab-backup",
|
||||||
|
"format_version": 1,
|
||||||
|
"app_version": __version__,
|
||||||
|
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||||
|
"store_count": len(stores),
|
||||||
|
},
|
||||||
|
"stores": stores,
|
||||||
|
}
|
||||||
|
|
||||||
|
content = json.dumps(backup, indent=2, ensure_ascii=False)
|
||||||
|
timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H%M%S")
|
||||||
|
filename = f"ledgrab-backup-{timestamp}.json"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(content.encode("utf-8")),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
|
||||||
|
async def restore_config(
|
||||||
|
_: AuthRequired,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
):
|
||||||
|
"""Upload a backup file to restore all configuration. Triggers server restart."""
|
||||||
|
# Read and parse
|
||||||
|
try:
|
||||||
|
raw = await file.read()
|
||||||
|
if len(raw) > 10 * 1024 * 1024: # 10 MB limit
|
||||||
|
raise HTTPException(status_code=400, detail="Backup file too large (max 10 MB)")
|
||||||
|
backup = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}")
|
||||||
|
|
||||||
|
# Validate envelope
|
||||||
|
meta = backup.get("meta")
|
||||||
|
if not isinstance(meta, dict) or meta.get("format") != "ledgrab-backup":
|
||||||
|
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup file")
|
||||||
|
|
||||||
|
fmt_version = meta.get("format_version", 0)
|
||||||
|
if fmt_version > 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Backup format version {fmt_version} is not supported by this server version",
|
||||||
|
)
|
||||||
|
|
||||||
|
stores = backup.get("stores")
|
||||||
|
if not isinstance(stores, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="Backup file missing 'stores' section")
|
||||||
|
|
||||||
|
known_keys = set(STORE_MAP.keys())
|
||||||
|
present_keys = known_keys & set(stores.keys())
|
||||||
|
if not present_keys:
|
||||||
|
raise HTTPException(status_code=400, detail="Backup contains no recognized store data")
|
||||||
|
|
||||||
|
for key in present_keys:
|
||||||
|
if not isinstance(stores[key], dict):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object")
|
||||||
|
|
||||||
|
# Write store files atomically
|
||||||
|
config = get_config()
|
||||||
|
written = 0
|
||||||
|
for store_key, config_attr in STORE_MAP.items():
|
||||||
|
if store_key in stores:
|
||||||
|
file_path = Path(getattr(config.storage, config_attr))
|
||||||
|
atomic_write_json(file_path, stores[store_key])
|
||||||
|
written += 1
|
||||||
|
logger.info(f"Restored store: {store_key} -> {file_path}")
|
||||||
|
|
||||||
|
logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...")
|
||||||
|
_schedule_restart()
|
||||||
|
|
||||||
|
missing = known_keys - present_keys
|
||||||
|
return RestoreResponse(
|
||||||
|
status="restored",
|
||||||
|
stores_written=written,
|
||||||
|
stores_total=len(STORE_MAP),
|
||||||
|
missing_stores=sorted(missing) if missing else [],
|
||||||
|
restart_scheduled=True,
|
||||||
|
message=f"Restored {written} stores. Server restarting...",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ADB helpers (for Android / scrcpy engine)
|
# ADB helpers (for Android / scrcpy engine)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ async def update_value_source(
|
|||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v1/value-sources/{source_id}", tags=["Value Sources"])
|
@router.delete("/api/v1/value-sources/{source_id}", status_code=204, tags=["Value Sources"])
|
||||||
async def delete_value_source(
|
async def delete_value_source(
|
||||||
source_id: str,
|
source_id: str,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
@@ -171,7 +171,6 @@ async def delete_value_source(
|
|||||||
)
|
)
|
||||||
|
|
||||||
store.delete_source(source_id)
|
store.delete_source(source_id)
|
||||||
return {"status": "deleted", "id": source_id}
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -68,3 +68,14 @@ class PerformanceResponse(BaseModel):
|
|||||||
ram_percent: float = Field(description="RAM usage percent")
|
ram_percent: float = Field(description="RAM usage percent")
|
||||||
gpu: GpuInfo | None = Field(default=None, description="GPU info (null if unavailable)")
|
gpu: GpuInfo | None = Field(default=None, description="GPU info (null if unavailable)")
|
||||||
timestamp: datetime = Field(description="Measurement timestamp")
|
timestamp: datetime = Field(description="Measurement timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreResponse(BaseModel):
|
||||||
|
"""Response after restoring configuration backup."""
|
||||||
|
|
||||||
|
status: str = Field(description="Status of restore operation")
|
||||||
|
stores_written: int = Field(description="Number of stores successfully written")
|
||||||
|
stores_total: int = Field(description="Total number of known stores")
|
||||||
|
missing_stores: List[str] = Field(default_factory=list, description="Store keys not found in backup")
|
||||||
|
restart_scheduled: bool = Field(description="Whether server restart was scheduled")
|
||||||
|
message: str = Field(description="Human-readable status message")
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ class AudioCaptureManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
key = (engine_type, device_index, is_loopback)
|
key = (engine_type, device_index, is_loopback)
|
||||||
|
stream_to_stop = None
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if key not in self._streams:
|
if key not in self._streams:
|
||||||
logger.warning(f"Attempted to release unknown audio capture: {key}")
|
logger.warning(f"Attempted to release unknown audio capture: {key}")
|
||||||
@@ -230,22 +231,27 @@ class AudioCaptureManager:
|
|||||||
stream, ref_count = self._streams[key]
|
stream, ref_count = self._streams[key]
|
||||||
ref_count -= 1
|
ref_count -= 1
|
||||||
if ref_count <= 0:
|
if ref_count <= 0:
|
||||||
stream.stop()
|
stream_to_stop = stream
|
||||||
del self._streams[key]
|
del self._streams[key]
|
||||||
logger.info(f"Removed audio capture {key}")
|
logger.info(f"Removed audio capture {key}")
|
||||||
else:
|
else:
|
||||||
self._streams[key] = (stream, ref_count)
|
self._streams[key] = (stream, ref_count)
|
||||||
logger.debug(f"Released audio capture {key} (ref_count={ref_count})")
|
logger.debug(f"Released audio capture {key} (ref_count={ref_count})")
|
||||||
|
# Stop outside the lock — stream.stop() joins a thread (up to 5s)
|
||||||
|
if stream_to_stop is not None:
|
||||||
|
stream_to_stop.stop()
|
||||||
|
|
||||||
def release_all(self) -> None:
|
def release_all(self) -> None:
|
||||||
"""Stop and remove all capture streams. Called on shutdown."""
|
"""Stop and remove all capture streams. Called on shutdown."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
for key, (stream, _) in list(self._streams.items()):
|
streams_to_stop = list(self._streams.items())
|
||||||
|
self._streams.clear()
|
||||||
|
# Stop outside the lock — each stop() joins a thread
|
||||||
|
for key, (stream, _) in streams_to_stop:
|
||||||
try:
|
try:
|
||||||
stream.stop()
|
stream.stop()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping audio capture {key}: {e}")
|
logger.error(f"Error stopping audio capture {key}: {e}")
|
||||||
self._streams.clear()
|
|
||||||
logger.info("Released all audio capture streams")
|
logger.info("Released all audio capture streams")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -193,12 +193,13 @@ class DDPClient:
|
|||||||
try:
|
try:
|
||||||
# Send plain RGB — WLED handles per-bus color order conversion
|
# Send plain RGB — WLED handles per-bus color order conversion
|
||||||
# internally when outputting to hardware.
|
# internally when outputting to hardware.
|
||||||
|
# Convert to numpy to avoid per-pixel Python loop
|
||||||
bpp = 4 if self.rgbw else 3 # bytes per pixel
|
bpp = 4 if self.rgbw else 3 # bytes per pixel
|
||||||
pixel_bytes = bytearray()
|
pixel_array = np.array(pixels, dtype=np.uint8)
|
||||||
for r, g, b in pixels:
|
|
||||||
pixel_bytes.extend((int(r), int(g), int(b)))
|
|
||||||
if self.rgbw:
|
if self.rgbw:
|
||||||
pixel_bytes.append(0) # White channel = 0
|
white = np.zeros((pixel_array.shape[0], 1), dtype=np.uint8)
|
||||||
|
pixel_array = np.hstack((pixel_array, white))
|
||||||
|
pixel_bytes = pixel_array.tobytes()
|
||||||
|
|
||||||
total_bytes = len(pixel_bytes)
|
total_bytes = len(pixel_bytes)
|
||||||
# Align payload to full pixels (multiple of bpp) to avoid splitting
|
# Align payload to full pixels (multiple of bpp) to avoid splitting
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||||
@@ -37,12 +38,12 @@ class PixelateFilter(PostprocessingFilter):
|
|||||||
|
|
||||||
h, w = image.shape[:2]
|
h, w = image.shape[:2]
|
||||||
|
|
||||||
for y in range(0, h, block_size):
|
# Resize down (area averaging) then up (nearest neighbor) —
|
||||||
for x in range(0, w, block_size):
|
# vectorized C++ instead of per-block Python loop
|
||||||
y_end = min(y + block_size, h)
|
small_w = max(1, w // block_size)
|
||||||
x_end = min(x + block_size, w)
|
small_h = max(1, h // block_size)
|
||||||
block = image[y:y_end, x:x_end]
|
small = cv2.resize(image, (small_w, small_h), interpolation=cv2.INTER_AREA)
|
||||||
mean_color = block.mean(axis=(0, 1)).astype(np.uint8)
|
pixelated = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST)
|
||||||
image[y:y_end, x:x_end] = mean_color
|
np.copyto(image, pixelated)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -229,10 +229,12 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
"vu_meter": self._render_vu_meter,
|
"vu_meter": self._render_vu_meter,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
with high_resolution_timer():
|
with high_resolution_timer():
|
||||||
while self._running:
|
while self._running:
|
||||||
loop_start = time.perf_counter()
|
loop_start = time.perf_counter()
|
||||||
frame_time = 1.0 / self._fps
|
frame_time = 1.0 / self._fps
|
||||||
|
try:
|
||||||
n = self._led_count
|
n = self._led_count
|
||||||
|
|
||||||
# Rebuild scratch buffers and pre-computed arrays when LED count changes
|
# Rebuild scratch buffers and pre-computed arrays when LED count changes
|
||||||
@@ -283,9 +285,15 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
"audio_render_ms": render_ms,
|
"audio_render_ms": render_ms,
|
||||||
"total_ms": read_ms + fft_ms + render_ms,
|
"total_ms": read_ms + fft_ms + render_ms,
|
||||||
}
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AudioColorStripStream render error: {e}")
|
||||||
|
|
||||||
elapsed = time.perf_counter() - loop_start
|
elapsed = time.perf_counter() - loop_start
|
||||||
time.sleep(max(frame_time - elapsed, 0.001))
|
time.sleep(max(frame_time - elapsed, 0.001))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal AudioColorStripStream loop error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
# ── Channel selection ─────────────────────────────────────────
|
# ── Channel selection ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
led_colors = frame_buf
|
led_colors = frame_buf
|
||||||
return led_colors
|
return led_colors
|
||||||
|
|
||||||
|
try:
|
||||||
with high_resolution_timer():
|
with high_resolution_timer():
|
||||||
while self._running:
|
while self._running:
|
||||||
loop_start = time.perf_counter()
|
loop_start = time.perf_counter()
|
||||||
@@ -473,6 +474,10 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
remaining = frame_time - elapsed
|
remaining = frame_time - elapsed
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
time.sleep(remaining)
|
time.sleep(remaining)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal PictureColorStripStream loop error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
|
||||||
def _compute_gradient_colors(stops: list, led_count: int) -> np.ndarray:
|
def _compute_gradient_colors(stops: list, led_count: int) -> np.ndarray:
|
||||||
@@ -506,30 +511,42 @@ def _compute_gradient_colors(stops: list, led_count: int) -> np.ndarray:
|
|||||||
c = stop.get("color", [255, 255, 255])
|
c = stop.get("color", [255, 255, 255])
|
||||||
return np.array(c if isinstance(c, list) and len(c) == 3 else [255, 255, 255], dtype=np.float32)
|
return np.array(c if isinstance(c, list) and len(c) == 3 else [255, 255, 255], dtype=np.float32)
|
||||||
|
|
||||||
|
# Vectorized: compute all LED positions at once
|
||||||
|
positions = np.linspace(0, 1, led_count) if led_count > 1 else np.array([0.0])
|
||||||
result = np.zeros((led_count, 3), dtype=np.float32)
|
result = np.zeros((led_count, 3), dtype=np.float32)
|
||||||
|
|
||||||
for i in range(led_count):
|
# Extract stop positions and colors into arrays
|
||||||
p = i / (led_count - 1) if led_count > 1 else 0.0
|
n_stops = len(sorted_stops)
|
||||||
|
stop_positions = np.array([float(s.get("position", 0)) for s in sorted_stops], dtype=np.float32)
|
||||||
|
|
||||||
if p <= float(sorted_stops[0].get("position", 0)):
|
# Pre-compute left/right colors for each stop
|
||||||
result[i] = _color(sorted_stops[0], "left")
|
left_colors = np.array([_color(s, "left") for s in sorted_stops], dtype=np.float32)
|
||||||
continue
|
right_colors = np.array([_color(s, "right") for s in sorted_stops], dtype=np.float32)
|
||||||
|
|
||||||
last = sorted_stops[-1]
|
# LEDs before first stop
|
||||||
if p >= float(last.get("position", 1)):
|
mask_before = positions <= stop_positions[0]
|
||||||
result[i] = _color(last, "right")
|
result[mask_before] = left_colors[0]
|
||||||
continue
|
|
||||||
|
|
||||||
for j in range(len(sorted_stops) - 1):
|
# LEDs after last stop
|
||||||
a = sorted_stops[j]
|
mask_after = positions >= stop_positions[-1]
|
||||||
b = sorted_stops[j + 1]
|
result[mask_after] = right_colors[-1]
|
||||||
a_pos = float(a.get("position", 0))
|
|
||||||
b_pos = float(b.get("position", 1))
|
# LEDs between stops — vectorized per segment
|
||||||
if a_pos <= p <= b_pos:
|
mask_between = ~mask_before & ~mask_after
|
||||||
|
if np.any(mask_between):
|
||||||
|
between_pos = positions[mask_between]
|
||||||
|
# np.searchsorted finds the right stop index for each LED
|
||||||
|
idx = np.searchsorted(stop_positions, between_pos, side="right") - 1
|
||||||
|
idx = np.clip(idx, 0, n_stops - 2)
|
||||||
|
|
||||||
|
a_pos = stop_positions[idx]
|
||||||
|
b_pos = stop_positions[idx + 1]
|
||||||
span = b_pos - a_pos
|
span = b_pos - a_pos
|
||||||
t = (p - a_pos) / span if span > 0 else 0.0
|
t = np.where(span > 0, (between_pos - a_pos) / span, 0.0)
|
||||||
result[i] = _color(a, "right") + t * (_color(b, "left") - _color(a, "right"))
|
|
||||||
break
|
a_colors = right_colors[idx] # A's right color
|
||||||
|
b_colors = left_colors[idx + 1] # B's left color
|
||||||
|
result[mask_between] = a_colors + t[:, np.newaxis] * (b_colors - a_colors)
|
||||||
|
|
||||||
return np.clip(result, 0, 255).astype(np.uint8)
|
return np.clip(result, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
@@ -646,10 +663,12 @@ class StaticColorStripStream(ColorStripStream):
|
|||||||
_buf_a = _buf_b = None
|
_buf_a = _buf_b = None
|
||||||
_use_a = True
|
_use_a = True
|
||||||
|
|
||||||
|
try:
|
||||||
with high_resolution_timer():
|
with high_resolution_timer():
|
||||||
while self._running:
|
while self._running:
|
||||||
loop_start = time.perf_counter()
|
loop_start = time.perf_counter()
|
||||||
frame_time = 1.0 / self._fps
|
frame_time = 1.0 / self._fps
|
||||||
|
try:
|
||||||
anim = self._animation
|
anim = self._animation
|
||||||
if anim and anim.get("enabled"):
|
if anim and anim.get("enabled"):
|
||||||
speed = float(anim.get("speed", 1.0))
|
speed = float(anim.get("speed", 1.0))
|
||||||
@@ -726,10 +745,16 @@ class StaticColorStripStream(ColorStripStream):
|
|||||||
if colors is not None:
|
if colors is not None:
|
||||||
with self._colors_lock:
|
with self._colors_lock:
|
||||||
self._colors = colors
|
self._colors = colors
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"StaticColorStripStream animation error: {e}")
|
||||||
|
|
||||||
elapsed = time.perf_counter() - loop_start
|
elapsed = time.perf_counter() - loop_start
|
||||||
sleep_target = frame_time if anim and anim.get("enabled") else 0.25
|
sleep_target = frame_time if anim and anim.get("enabled") else 0.25
|
||||||
time.sleep(max(sleep_target - elapsed, 0.001))
|
time.sleep(max(sleep_target - elapsed, 0.001))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal StaticColorStripStream loop error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
|
||||||
class ColorCycleColorStripStream(ColorStripStream):
|
class ColorCycleColorStripStream(ColorStripStream):
|
||||||
@@ -834,10 +859,12 @@ class ColorCycleColorStripStream(ColorStripStream):
|
|||||||
_buf_a = _buf_b = None
|
_buf_a = _buf_b = None
|
||||||
_use_a = True
|
_use_a = True
|
||||||
|
|
||||||
|
try:
|
||||||
with high_resolution_timer():
|
with high_resolution_timer():
|
||||||
while self._running:
|
while self._running:
|
||||||
loop_start = time.perf_counter()
|
loop_start = time.perf_counter()
|
||||||
frame_time = 1.0 / self._fps
|
frame_time = 1.0 / self._fps
|
||||||
|
try:
|
||||||
color_list = self._color_list
|
color_list = self._color_list
|
||||||
speed = self._cycle_speed
|
speed = self._cycle_speed
|
||||||
n = self._led_count
|
n = self._led_count
|
||||||
@@ -865,8 +892,14 @@ class ColorCycleColorStripStream(ColorStripStream):
|
|||||||
)
|
)
|
||||||
with self._colors_lock:
|
with self._colors_lock:
|
||||||
self._colors = buf
|
self._colors = buf
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"ColorCycleColorStripStream animation error: {e}")
|
||||||
elapsed = time.perf_counter() - loop_start
|
elapsed = time.perf_counter() - loop_start
|
||||||
time.sleep(max(frame_time - elapsed, 0.001))
|
time.sleep(max(frame_time - elapsed, 0.001))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal ColorCycleColorStripStream loop error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
|
||||||
class GradientColorStripStream(ColorStripStream):
|
class GradientColorStripStream(ColorStripStream):
|
||||||
@@ -986,10 +1019,12 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
_wave_factors = None # float32 scratch for wave sin result
|
_wave_factors = None # float32 scratch for wave sin result
|
||||||
_wave_u16 = None # uint16 scratch for wave int factors
|
_wave_u16 = None # uint16 scratch for wave int factors
|
||||||
|
|
||||||
|
try:
|
||||||
with high_resolution_timer():
|
with high_resolution_timer():
|
||||||
while self._running:
|
while self._running:
|
||||||
loop_start = time.perf_counter()
|
loop_start = time.perf_counter()
|
||||||
frame_time = 1.0 / self._fps
|
frame_time = 1.0 / self._fps
|
||||||
|
try:
|
||||||
anim = self._animation
|
anim = self._animation
|
||||||
if anim and anim.get("enabled"):
|
if anim and anim.get("enabled"):
|
||||||
speed = float(anim.get("speed", 1.0))
|
speed = float(anim.get("speed", 1.0))
|
||||||
@@ -1109,7 +1144,13 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
if colors is not None:
|
if colors is not None:
|
||||||
with self._colors_lock:
|
with self._colors_lock:
|
||||||
self._colors = colors
|
self._colors = colors
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"GradientColorStripStream animation error: {e}")
|
||||||
|
|
||||||
elapsed = time.perf_counter() - loop_start
|
elapsed = time.perf_counter() - loop_start
|
||||||
sleep_target = frame_time if anim and anim.get("enabled") else 0.25
|
sleep_target = frame_time if anim and anim.get("enabled") else 0.25
|
||||||
time.sleep(max(sleep_target - elapsed, 0.001))
|
time.sleep(max(sleep_target - elapsed, 0.001))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal GradientColorStripStream loop error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
self._running = False
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
# ── Processing loop ─────────────────────────────────────────
|
# ── Processing loop ─────────────────────────────────────────
|
||||||
|
|
||||||
def _processing_loop(self) -> None:
|
def _processing_loop(self) -> None:
|
||||||
|
try:
|
||||||
while self._running:
|
while self._running:
|
||||||
loop_start = time.perf_counter()
|
loop_start = time.perf_counter()
|
||||||
frame_time = 1.0 / self._fps
|
frame_time = 1.0 / self._fps
|
||||||
@@ -311,3 +312,7 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
elapsed = time.perf_counter() - loop_start
|
elapsed = time.perf_counter() - loop_start
|
||||||
time.sleep(max(frame_time - elapsed, 0.001))
|
time.sleep(max(frame_time - elapsed, 0.001))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal CompositeColorStripStream loop error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
self._running = False
|
||||||
|
|||||||
@@ -284,11 +284,12 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
"aurora": self._render_aurora,
|
"aurora": self._render_aurora,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
with high_resolution_timer():
|
with high_resolution_timer():
|
||||||
while self._running:
|
while self._running:
|
||||||
loop_start = time.perf_counter()
|
loop_start = time.perf_counter()
|
||||||
frame_time = 1.0 / self._fps
|
frame_time = 1.0 / self._fps
|
||||||
|
try:
|
||||||
n = self._led_count
|
n = self._led_count
|
||||||
if n != _pool_n:
|
if n != _pool_n:
|
||||||
_pool_n = n
|
_pool_n = n
|
||||||
@@ -313,9 +314,15 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
with self._colors_lock:
|
with self._colors_lock:
|
||||||
self._colors = buf
|
self._colors = buf
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"EffectColorStripStream render error: {e}")
|
||||||
|
|
||||||
elapsed = time.perf_counter() - loop_start
|
elapsed = time.perf_counter() - loop_start
|
||||||
time.sleep(max(frame_time - elapsed, 0.001))
|
time.sleep(max(frame_time - elapsed, 0.001))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal EffectColorStripStream loop error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
# ── Fire ─────────────────────────────────────────────────────────
|
# ── Fire ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ class ScreenCaptureLiveStream(LiveStream):
|
|||||||
|
|
||||||
def _capture_loop(self) -> None:
|
def _capture_loop(self) -> None:
|
||||||
frame_time = 1.0 / self._fps if self._fps > 0 else 1.0
|
frame_time = 1.0 / self._fps if self._fps > 0 else 1.0
|
||||||
|
consecutive_errors = 0
|
||||||
|
try:
|
||||||
with high_resolution_timer():
|
with high_resolution_timer():
|
||||||
while self._running:
|
while self._running:
|
||||||
loop_start = time.perf_counter()
|
loop_start = time.perf_counter()
|
||||||
@@ -137,17 +139,31 @@ class ScreenCaptureLiveStream(LiveStream):
|
|||||||
if frame is not None:
|
if frame is not None:
|
||||||
with self._frame_lock:
|
with self._frame_lock:
|
||||||
self._latest_frame = frame
|
self._latest_frame = frame
|
||||||
|
consecutive_errors = 0
|
||||||
else:
|
else:
|
||||||
# Small sleep when no frame available to avoid CPU spinning
|
# Small sleep when no frame available to avoid CPU spinning
|
||||||
time.sleep(0.001)
|
time.sleep(0.001)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
consecutive_errors += 1
|
||||||
logger.error(f"Capture error (display={self._capture_stream.display_index}): {e}")
|
logger.error(f"Capture error (display={self._capture_stream.display_index}): {e}")
|
||||||
|
# Backoff on repeated errors to avoid CPU spinning
|
||||||
|
if consecutive_errors > 5:
|
||||||
|
backoff = min(1.0, 0.1 * (consecutive_errors - 5))
|
||||||
|
time.sleep(backoff)
|
||||||
|
continue
|
||||||
|
|
||||||
# Throttle to target FPS
|
# Throttle to target FPS
|
||||||
elapsed = time.perf_counter() - loop_start
|
elapsed = time.perf_counter() - loop_start
|
||||||
remaining = frame_time - elapsed
|
remaining = frame_time - elapsed
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
time.sleep(remaining)
|
time.sleep(remaining)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Fatal capture loop error (display={self._capture_stream.display_index}): {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
|
||||||
class ProcessedLiveStream(LiveStream):
|
class ProcessedLiveStream(LiveStream):
|
||||||
@@ -226,10 +242,11 @@ class ProcessedLiveStream(LiveStream):
|
|||||||
fps = self.target_fps
|
fps = self.target_fps
|
||||||
frame_time = 1.0 / fps if fps > 0 else 1.0
|
frame_time = 1.0 / fps if fps > 0 else 1.0
|
||||||
|
|
||||||
|
try:
|
||||||
with high_resolution_timer():
|
with high_resolution_timer():
|
||||||
while self._running:
|
while self._running:
|
||||||
loop_start = time.perf_counter()
|
loop_start = time.perf_counter()
|
||||||
|
try:
|
||||||
source_frame = self._source.get_latest_frame()
|
source_frame = self._source.get_latest_frame()
|
||||||
if source_frame is None or source_frame is cached_source_frame:
|
if source_frame is None or source_frame is cached_source_frame:
|
||||||
# Idle tick — run filter chain when any filter requests idle processing
|
# Idle tick — run filter chain when any filter requests idle processing
|
||||||
@@ -250,9 +267,6 @@ class ProcessedLiveStream(LiveStream):
|
|||||||
|
|
||||||
# Only publish a new frame when the filter chain produced actual
|
# Only publish a new frame when the filter chain produced actual
|
||||||
# interpolated output (idle_image advanced past the input buffer).
|
# interpolated output (idle_image advanced past the input buffer).
|
||||||
# If every filter passed through, idle_image is still _idle_src_buf —
|
|
||||||
# leave _latest_frame unchanged so consumers that rely on object
|
|
||||||
# identity for deduplication correctly detect no new content.
|
|
||||||
if idle_image is not _idle_src_buf:
|
if idle_image is not _idle_src_buf:
|
||||||
processed = ScreenCapture(
|
processed = ScreenCapture(
|
||||||
image=idle_image,
|
image=idle_image,
|
||||||
@@ -299,6 +313,13 @@ class ProcessedLiveStream(LiveStream):
|
|||||||
)
|
)
|
||||||
with self._frame_lock:
|
with self._frame_lock:
|
||||||
self._latest_frame = processed
|
self._latest_frame = processed
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Filter processing error: {e}")
|
||||||
|
time.sleep(0.01)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal processing loop error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
|
||||||
class StaticImageLiveStream(LiveStream):
|
class StaticImageLiveStream(LiveStream):
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ class MappedColorStripStream(ColorStripStream):
|
|||||||
# ── Processing loop ─────────────────────────────────────────
|
# ── Processing loop ─────────────────────────────────────────
|
||||||
|
|
||||||
def _processing_loop(self) -> None:
|
def _processing_loop(self) -> None:
|
||||||
|
try:
|
||||||
while self._running:
|
while self._running:
|
||||||
loop_start = time.perf_counter()
|
loop_start = time.perf_counter()
|
||||||
frame_time = 1.0 / self._fps
|
frame_time = 1.0 / self._fps
|
||||||
@@ -210,3 +211,7 @@ class MappedColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
elapsed = time.perf_counter() - loop_start
|
elapsed = time.perf_counter() - loop_start
|
||||||
time.sleep(max(frame_time - elapsed, 0.001))
|
time.sleep(max(frame_time - elapsed, 0.001))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal MappedColorStripStream loop error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
self._running = False
|
||||||
|
|||||||
@@ -131,10 +131,13 @@ import {
|
|||||||
showCSSCalibration, toggleCalibrationOverlay,
|
showCSSCalibration, toggleCalibrationOverlay,
|
||||||
} from './features/calibration.js';
|
} from './features/calibration.js';
|
||||||
|
|
||||||
// Layer 6: tabs, navigation, command palette
|
// Layer 6: tabs, navigation, command palette, settings
|
||||||
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.js';
|
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.js';
|
||||||
import { navigateToCard } from './core/navigation.js';
|
import { navigateToCard } from './core/navigation.js';
|
||||||
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js';
|
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js';
|
||||||
|
import {
|
||||||
|
openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected,
|
||||||
|
} from './features/settings.js';
|
||||||
|
|
||||||
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||||
|
|
||||||
@@ -384,6 +387,12 @@ Object.assign(window, {
|
|||||||
navigateToCard,
|
navigateToCard,
|
||||||
openCommandPalette,
|
openCommandPalette,
|
||||||
closeCommandPalette,
|
closeCommandPalette,
|
||||||
|
|
||||||
|
// settings (backup / restore)
|
||||||
|
openSettingsModal,
|
||||||
|
closeSettingsModal,
|
||||||
|
downloadBackup,
|
||||||
|
handleRestoreFileSelected,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Global keyboard shortcuts ───
|
// ─── Global keyboard shortcuts ───
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export async function fetchWithAuth(url, options = {}) {
|
|||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
if (fetchOpts.signal) {
|
if (fetchOpts.signal) {
|
||||||
fetchOpts.signal.addEventListener('abort', () => controller.abort());
|
fetchOpts.signal.addEventListener('abort', () => controller.abort(), { once: true });
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => controller.abort(), timeout);
|
const timer = setTimeout(() => controller.abort(), timeout);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -56,17 +56,26 @@ export function setupBackdropClose(modal, closeFn) {
|
|||||||
modal._backdropCloseSetup = true;
|
modal._backdropCloseSetup = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _lockCount = 0;
|
||||||
|
let _savedScrollY = 0;
|
||||||
|
|
||||||
export function lockBody() {
|
export function lockBody() {
|
||||||
const scrollY = window.scrollY;
|
if (_lockCount === 0) {
|
||||||
document.body.style.top = `-${scrollY}px`;
|
_savedScrollY = window.scrollY;
|
||||||
|
document.body.style.top = `-${_savedScrollY}px`;
|
||||||
document.body.classList.add('modal-open');
|
document.body.classList.add('modal-open');
|
||||||
|
}
|
||||||
|
_lockCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unlockBody() {
|
export function unlockBody() {
|
||||||
const scrollY = parseInt(document.body.style.top || '0', 10) * -1;
|
if (_lockCount <= 0) return;
|
||||||
|
_lockCount--;
|
||||||
|
if (_lockCount === 0) {
|
||||||
document.body.classList.remove('modal-open');
|
document.body.classList.remove('modal-open');
|
||||||
document.body.style.top = '';
|
document.body.style.top = '';
|
||||||
window.scrollTo(0, scrollY);
|
window.scrollTo(0, _savedScrollY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openLightbox(imageSrc, statsHtml) {
|
export function openLightbox(imageSrc, statsHtml) {
|
||||||
|
|||||||
137
server/src/wled_controller/static/js/features/settings.js
Normal file
137
server/src/wled_controller/static/js/features/settings.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Settings — backup / restore configuration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiKey } from '../core/state.js';
|
||||||
|
import { API_BASE, fetchWithAuth } from '../core/api.js';
|
||||||
|
import { Modal } from '../core/modal.js';
|
||||||
|
import { showToast, showConfirm } from '../core/ui.js';
|
||||||
|
import { t } from '../core/i18n.js';
|
||||||
|
|
||||||
|
// Simple modal (no form / no dirty check needed)
|
||||||
|
const settingsModal = new Modal('settings-modal');
|
||||||
|
|
||||||
|
export function openSettingsModal() {
|
||||||
|
document.getElementById('settings-error').style.display = 'none';
|
||||||
|
settingsModal.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSettingsModal() {
|
||||||
|
settingsModal.forceClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Backup ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function downloadBackup() {
|
||||||
|
try {
|
||||||
|
const resp = await fetchWithAuth('/system/backup', { timeout: 30000 });
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const disposition = resp.headers.get('Content-Disposition') || '';
|
||||||
|
const match = disposition.match(/filename="(.+?)"/);
|
||||||
|
const filename = match ? match[1] : 'ledgrab-backup.json';
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
|
||||||
|
showToast(t('settings.backup.success'), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Backup download failed:', err);
|
||||||
|
showToast(t('settings.backup.error') + ': ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Restore ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function handleRestoreFileSelected(input) {
|
||||||
|
const file = input.files[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const confirmed = await showConfirm(t('settings.restore.confirm'));
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const resp = await fetch(`${API_BASE}/system/restore`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${apiKey}` },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
showToast(data.message || t('settings.restore.success'), 'success');
|
||||||
|
settingsModal.forceClose();
|
||||||
|
|
||||||
|
if (data.restart_scheduled) {
|
||||||
|
showRestartOverlay();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Restore failed:', err);
|
||||||
|
showToast(t('settings.restore.error') + ': ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Restart overlay ───────────────────────────────────────
|
||||||
|
|
||||||
|
function showRestartOverlay() {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'restart-overlay';
|
||||||
|
overlay.style.cssText =
|
||||||
|
'position:fixed;inset:0;z-index:100000;display:flex;flex-direction:column;' +
|
||||||
|
'align-items:center;justify-content:center;background:rgba(0,0,0,0.85);color:#fff;font-size:1.2rem;';
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div class="spinner" style="width:48px;height:48px;border:4px solid rgba(255,255,255,0.3);' +
|
||||||
|
'border-top-color:#fff;border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:1rem;"></div>' +
|
||||||
|
`<div id="restart-msg">${t('settings.restore.restarting')}</div>`;
|
||||||
|
|
||||||
|
// Add spinner animation if not present
|
||||||
|
if (!document.getElementById('restart-spinner-style')) {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'restart-spinner-style';
|
||||||
|
style.textContent = '@keyframes spin{to{transform:rotate(360deg)}}';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
pollHealth();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollHealth() {
|
||||||
|
const start = Date.now();
|
||||||
|
const maxWait = 30000;
|
||||||
|
const interval = 1500;
|
||||||
|
|
||||||
|
const check = async () => {
|
||||||
|
if (Date.now() - start > maxWait) {
|
||||||
|
const msg = document.getElementById('restart-msg');
|
||||||
|
if (msg) msg.textContent = t('settings.restore.restart_timeout');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/health', { signal: AbortSignal.timeout(3000) });
|
||||||
|
if (resp.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* server still down */ }
|
||||||
|
setTimeout(check, interval);
|
||||||
|
};
|
||||||
|
// Wait a moment before first check to let the server shut down
|
||||||
|
setTimeout(check, 2000);
|
||||||
|
}
|
||||||
@@ -1102,6 +1102,7 @@ function connectLedPreviewWS(targetId) {
|
|||||||
function disconnectLedPreviewWS(targetId) {
|
function disconnectLedPreviewWS(targetId) {
|
||||||
const ws = ledPreviewWebSockets[targetId];
|
const ws = ledPreviewWebSockets[targetId];
|
||||||
if (ws) {
|
if (ws) {
|
||||||
|
ws.onclose = null;
|
||||||
ws.close();
|
ws.close();
|
||||||
delete ledPreviewWebSockets[targetId];
|
delete ledPreviewWebSockets[targetId];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -927,5 +927,21 @@
|
|||||||
"search.group.pp_templates": "Post-Processing Templates",
|
"search.group.pp_templates": "Post-Processing Templates",
|
||||||
"search.group.pattern_templates": "Pattern Templates",
|
"search.group.pattern_templates": "Pattern Templates",
|
||||||
"search.group.audio": "Audio Sources",
|
"search.group.audio": "Audio Sources",
|
||||||
"search.group.value": "Value Sources"
|
"search.group.value": "Value Sources",
|
||||||
|
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.backup.label": "Backup Configuration",
|
||||||
|
"settings.backup.hint": "Download all configuration (devices, targets, streams, templates, profiles) as a single JSON file.",
|
||||||
|
"settings.backup.button": "Download Backup",
|
||||||
|
"settings.backup.success": "Backup downloaded successfully",
|
||||||
|
"settings.backup.error": "Backup download failed",
|
||||||
|
"settings.restore.label": "Restore Configuration",
|
||||||
|
"settings.restore.hint": "Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.",
|
||||||
|
"settings.restore.button": "Restore from Backup",
|
||||||
|
"settings.restore.confirm": "This will replace ALL configuration and restart the server. Are you sure?",
|
||||||
|
"settings.restore.success": "Configuration restored",
|
||||||
|
"settings.restore.error": "Restore failed",
|
||||||
|
"settings.restore.restarting": "Server is restarting...",
|
||||||
|
"settings.restore.restart_timeout": "Server did not respond. Please refresh the page manually.",
|
||||||
|
"settings.button.close": "Close"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -927,5 +927,21 @@
|
|||||||
"search.group.pp_templates": "Шаблоны постобработки",
|
"search.group.pp_templates": "Шаблоны постобработки",
|
||||||
"search.group.pattern_templates": "Шаблоны паттернов",
|
"search.group.pattern_templates": "Шаблоны паттернов",
|
||||||
"search.group.audio": "Аудиоисточники",
|
"search.group.audio": "Аудиоисточники",
|
||||||
"search.group.value": "Источники значений"
|
"search.group.value": "Источники значений",
|
||||||
|
|
||||||
|
"settings.title": "Настройки",
|
||||||
|
"settings.backup.label": "Резервное копирование",
|
||||||
|
"settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, профили) в виде одного JSON-файла.",
|
||||||
|
"settings.backup.button": "Скачать резервную копию",
|
||||||
|
"settings.backup.success": "Резервная копия скачана",
|
||||||
|
"settings.backup.error": "Ошибка скачивания резервной копии",
|
||||||
|
"settings.restore.label": "Восстановление конфигурации",
|
||||||
|
"settings.restore.hint": "Загрузите ранее сохранённый файл резервной копии для замены всей конфигурации. Сервер перезапустится автоматически.",
|
||||||
|
"settings.restore.button": "Восстановить из копии",
|
||||||
|
"settings.restore.confirm": "Это заменит ВСЮ конфигурацию и перезапустит сервер. Вы уверены?",
|
||||||
|
"settings.restore.success": "Конфигурация восстановлена",
|
||||||
|
"settings.restore.error": "Ошибка восстановления",
|
||||||
|
"settings.restore.restarting": "Сервер перезапускается...",
|
||||||
|
"settings.restore.restart_timeout": "Сервер не отвечает. Обновите страницу вручную.",
|
||||||
|
"settings.button.close": "Закрыть"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -927,5 +927,21 @@
|
|||||||
"search.group.pp_templates": "后处理模板",
|
"search.group.pp_templates": "后处理模板",
|
||||||
"search.group.pattern_templates": "图案模板",
|
"search.group.pattern_templates": "图案模板",
|
||||||
"search.group.audio": "音频源",
|
"search.group.audio": "音频源",
|
||||||
"search.group.value": "值源"
|
"search.group.value": "值源",
|
||||||
|
|
||||||
|
"settings.title": "设置",
|
||||||
|
"settings.backup.label": "备份配置",
|
||||||
|
"settings.backup.hint": "将所有配置(设备、目标、流、模板、配置文件)下载为单个 JSON 文件。",
|
||||||
|
"settings.backup.button": "下载备份",
|
||||||
|
"settings.backup.success": "备份下载成功",
|
||||||
|
"settings.backup.error": "备份下载失败",
|
||||||
|
"settings.restore.label": "恢复配置",
|
||||||
|
"settings.restore.hint": "上传之前下载的备份文件以替换所有配置。服务器将自动重启。",
|
||||||
|
"settings.restore.button": "从备份恢复",
|
||||||
|
"settings.restore.confirm": "这将替换所有配置并重启服务器。确定继续吗?",
|
||||||
|
"settings.restore.success": "配置已恢复",
|
||||||
|
"settings.restore.error": "恢复失败",
|
||||||
|
"settings.restore.restarting": "服务器正在重启...",
|
||||||
|
"settings.restore.restart_timeout": "服务器未响应。请手动刷新页面。",
|
||||||
|
"settings.button.close": "关闭"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from wled_controller.storage.audio_source import (
|
|||||||
MonoAudioSource,
|
MonoAudioSource,
|
||||||
MultichannelAudioSource,
|
MultichannelAudioSource,
|
||||||
)
|
)
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import atomic_write_json, get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -57,21 +57,14 @@ class AudioSourceStore:
|
|||||||
|
|
||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
try:
|
try:
|
||||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
sources_dict = {
|
|
||||||
sid: source.to_dict()
|
|
||||||
for sid, source in self._sources.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"audio_sources": sources_dict,
|
"audio_sources": {
|
||||||
|
sid: source.to_dict()
|
||||||
|
for sid, source in self._sources.items()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
atomic_write_json(self.file_path, data)
|
||||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save audio sources to {self.file_path}: {e}")
|
logger.error(f"Failed to save audio sources to {self.file_path}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
from wled_controller.core.audio.factory import AudioEngineRegistry
|
from wled_controller.core.audio.factory import AudioEngineRegistry
|
||||||
from wled_controller.storage.audio_template import AudioCaptureTemplate
|
from wled_controller.storage.audio_template import AudioCaptureTemplate
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import atomic_write_json, get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -93,21 +93,14 @@ class AudioTemplateStore:
|
|||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
"""Save all templates to file."""
|
"""Save all templates to file."""
|
||||||
try:
|
try:
|
||||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
templates_dict = {
|
|
||||||
template_id: template.to_dict()
|
|
||||||
for template_id, template in self._templates.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"templates": templates_dict,
|
"templates": {
|
||||||
|
template_id: template.to_dict()
|
||||||
|
for template_id, template in self._templates.items()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
atomic_write_json(self.file_path, data)
|
||||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save audio templates to {self.file_path}: {e}")
|
logger.error(f"Failed to save audio templates to {self.file_path}: {e}")
|
||||||
raise
|
raise
|
||||||
@@ -168,6 +161,9 @@ class AudioTemplateStore:
|
|||||||
template = self._templates[template_id]
|
template = self._templates[template_id]
|
||||||
|
|
||||||
if name is not None:
|
if name is not None:
|
||||||
|
for tid, t in self._templates.items():
|
||||||
|
if tid != template_id and t.name == name:
|
||||||
|
raise ValueError(f"Audio template with name '{name}' already exists")
|
||||||
template.name = name
|
template.name = name
|
||||||
if engine_type is not None:
|
if engine_type is not None:
|
||||||
template.engine_type = engine_type
|
template.engine_type = engine_type
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from wled_controller.storage.color_strip_source import (
|
|||||||
PictureColorStripSource,
|
PictureColorStripSource,
|
||||||
StaticColorStripSource,
|
StaticColorStripSource,
|
||||||
)
|
)
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import atomic_write_json, get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -62,21 +62,14 @@ class ColorStripStore:
|
|||||||
|
|
||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
try:
|
try:
|
||||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
sources_dict = {
|
|
||||||
sid: source.to_dict()
|
|
||||||
for sid, source in self._sources.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"color_strip_sources": sources_dict,
|
"color_strip_sources": {
|
||||||
|
sid: source.to_dict()
|
||||||
|
for sid, source in self._sources.items()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
atomic_write_json(self.file_path, data)
|
||||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save color strip sources to {self.file_path}: {e}")
|
logger.error(f"Failed to save color strip sources to {self.file_path}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
from wled_controller.storage.key_colors_picture_target import KeyColorRectangle
|
from wled_controller.storage.key_colors_picture_target import KeyColorRectangle
|
||||||
from wled_controller.storage.pattern_template import PatternTemplate
|
from wled_controller.storage.pattern_template import PatternTemplate
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import atomic_write_json, get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -88,21 +88,14 @@ class PatternTemplateStore:
|
|||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
"""Save all templates to file."""
|
"""Save all templates to file."""
|
||||||
try:
|
try:
|
||||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
templates_dict = {
|
|
||||||
template_id: template.to_dict()
|
|
||||||
for template_id, template in self._templates.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"pattern_templates": templates_dict,
|
"pattern_templates": {
|
||||||
|
template_id: template.to_dict()
|
||||||
|
for template_id, template in self._templates.items()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
atomic_write_json(self.file_path, data)
|
||||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save pattern templates to {self.file_path}: {e}")
|
logger.error(f"Failed to save pattern templates to {self.file_path}: {e}")
|
||||||
raise
|
raise
|
||||||
@@ -180,6 +173,9 @@ class PatternTemplateStore:
|
|||||||
template = self._templates[template_id]
|
template = self._templates[template_id]
|
||||||
|
|
||||||
if name is not None:
|
if name is not None:
|
||||||
|
for tid, t in self._templates.items():
|
||||||
|
if tid != template_id and t.name == name:
|
||||||
|
raise ValueError(f"Pattern template with name '{name}' already exists")
|
||||||
template.name = name
|
template.name = name
|
||||||
if rectangles is not None:
|
if rectangles is not None:
|
||||||
template.rectangles = rectangles
|
template.rectangles = rectangles
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from wled_controller.storage.picture_source import (
|
|||||||
ProcessedPictureSource,
|
ProcessedPictureSource,
|
||||||
StaticImagePictureSource,
|
StaticImagePictureSource,
|
||||||
)
|
)
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import atomic_write_json, get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -68,21 +68,14 @@ class PictureSourceStore:
|
|||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
"""Save all streams to file."""
|
"""Save all streams to file."""
|
||||||
try:
|
try:
|
||||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
streams_dict = {
|
|
||||||
stream_id: stream.to_dict()
|
|
||||||
for stream_id, stream in self._streams.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"picture_sources": streams_dict,
|
"picture_sources": {
|
||||||
|
stream_id: stream.to_dict()
|
||||||
|
for stream_id, stream in self._streams.items()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
atomic_write_json(self.file_path, data)
|
||||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save picture sources to {self.file_path}: {e}")
|
logger.error(f"Failed to save picture sources to {self.file_path}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from wled_controller.storage.key_colors_picture_target import (
|
|||||||
KeyColorsSettings,
|
KeyColorsSettings,
|
||||||
KeyColorsPictureTarget,
|
KeyColorsPictureTarget,
|
||||||
)
|
)
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import atomic_write_json, get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -63,21 +63,14 @@ class PictureTargetStore:
|
|||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
"""Save all targets to file."""
|
"""Save all targets to file."""
|
||||||
try:
|
try:
|
||||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
targets_dict = {
|
|
||||||
target_id: target.to_dict()
|
|
||||||
for target_id, target in self._targets.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"picture_targets": targets_dict,
|
"picture_targets": {
|
||||||
|
target_id: target.to_dict()
|
||||||
|
for target_id, target in self._targets.items()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
atomic_write_json(self.file_path, data)
|
||||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save picture targets to {self.file_path}: {e}")
|
logger.error(f"Failed to save picture targets to {self.file_path}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from wled_controller.core.filters.filter_instance import FilterInstance
|
|||||||
from wled_controller.core.filters.registry import FilterRegistry
|
from wled_controller.core.filters.registry import FilterRegistry
|
||||||
from wled_controller.storage.picture_source import ProcessedPictureSource
|
from wled_controller.storage.picture_source import ProcessedPictureSource
|
||||||
from wled_controller.storage.postprocessing_template import PostprocessingTemplate
|
from wled_controller.storage.postprocessing_template import PostprocessingTemplate
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import atomic_write_json, get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -92,21 +92,14 @@ class PostprocessingTemplateStore:
|
|||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
"""Save all templates to file."""
|
"""Save all templates to file."""
|
||||||
try:
|
try:
|
||||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
templates_dict = {
|
|
||||||
template_id: template.to_dict()
|
|
||||||
for template_id, template in self._templates.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"postprocessing_templates": templates_dict,
|
"postprocessing_templates": {
|
||||||
|
template_id: template.to_dict()
|
||||||
|
for template_id, template in self._templates.items()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
atomic_write_json(self.file_path, data)
|
||||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save postprocessing templates to {self.file_path}: {e}")
|
logger.error(f"Failed to save postprocessing templates to {self.file_path}: {e}")
|
||||||
raise
|
raise
|
||||||
@@ -189,6 +182,9 @@ class PostprocessingTemplateStore:
|
|||||||
template = self._templates[template_id]
|
template = self._templates[template_id]
|
||||||
|
|
||||||
if name is not None:
|
if name is not None:
|
||||||
|
for tid, t in self._templates.items():
|
||||||
|
if tid != template_id and t.name == name:
|
||||||
|
raise ValueError(f"Postprocessing template with name '{name}' already exists")
|
||||||
template.name = name
|
template.name = name
|
||||||
if filters is not None:
|
if filters is not None:
|
||||||
# Validate filter IDs
|
# Validate filter IDs
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from wled_controller.storage.profile import Condition, Profile
|
from wled_controller.storage.profile import Condition, Profile
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import atomic_write_json, get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -49,18 +49,13 @@ class ProfileStore:
|
|||||||
|
|
||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
try:
|
try:
|
||||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"profiles": {
|
"profiles": {
|
||||||
pid: p.to_dict() for pid, p in self._profiles.items()
|
pid: p.to_dict() for pid, p in self._profiles.items()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
atomic_write_json(self.file_path, data)
|
||||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save profiles to {self.file_path}: {e}")
|
logger.error(f"Failed to save profiles to {self.file_path}: {e}")
|
||||||
raise
|
raise
|
||||||
@@ -81,6 +76,10 @@ class ProfileStore:
|
|||||||
conditions: Optional[List[Condition]] = None,
|
conditions: Optional[List[Condition]] = None,
|
||||||
target_ids: Optional[List[str]] = None,
|
target_ids: Optional[List[str]] = None,
|
||||||
) -> Profile:
|
) -> Profile:
|
||||||
|
for p in self._profiles.values():
|
||||||
|
if p.name == name:
|
||||||
|
raise ValueError(f"Profile with name '{name}' already exists")
|
||||||
|
|
||||||
profile_id = f"prof_{uuid.uuid4().hex[:8]}"
|
profile_id = f"prof_{uuid.uuid4().hex[:8]}"
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
@@ -116,6 +115,9 @@ class ProfileStore:
|
|||||||
profile = self._profiles[profile_id]
|
profile = self._profiles[profile_id]
|
||||||
|
|
||||||
if name is not None:
|
if name is not None:
|
||||||
|
for pid, p in self._profiles.items():
|
||||||
|
if pid != profile_id and p.name == name:
|
||||||
|
raise ValueError(f"Profile with name '{name}' already exists")
|
||||||
profile.name = name
|
profile.name = name
|
||||||
if enabled is not None:
|
if enabled is not None:
|
||||||
profile.enabled = enabled
|
profile.enabled = enabled
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
from wled_controller.core.capture_engines.factory import EngineRegistry
|
from wled_controller.core.capture_engines.factory import EngineRegistry
|
||||||
from wled_controller.storage.template import CaptureTemplate
|
from wled_controller.storage.template import CaptureTemplate
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import atomic_write_json, get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -95,23 +95,14 @@ class TemplateStore:
|
|||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
"""Save all templates to file."""
|
"""Save all templates to file."""
|
||||||
try:
|
try:
|
||||||
# Ensure directory exists
|
|
||||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
templates_dict = {
|
|
||||||
template_id: template.to_dict()
|
|
||||||
for template_id, template in self._templates.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"templates": templates_dict,
|
"templates": {
|
||||||
|
template_id: template.to_dict()
|
||||||
|
for template_id, template in self._templates.items()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
atomic_write_json(self.file_path, data)
|
||||||
# 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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save templates to {self.file_path}: {e}")
|
logger.error(f"Failed to save templates to {self.file_path}: {e}")
|
||||||
raise
|
raise
|
||||||
@@ -218,6 +209,9 @@ class TemplateStore:
|
|||||||
|
|
||||||
# Update fields
|
# Update fields
|
||||||
if name is not None:
|
if name is not None:
|
||||||
|
for tid, t in self._templates.items():
|
||||||
|
if tid != template_id and t.name == name:
|
||||||
|
raise ValueError(f"Template with name '{name}' already exists")
|
||||||
template.name = name
|
template.name = name
|
||||||
if engine_type is not None:
|
if engine_type is not None:
|
||||||
template.engine_type = engine_type
|
template.engine_type = engine_type
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from wled_controller.storage.value_source import (
|
|||||||
StaticValueSource,
|
StaticValueSource,
|
||||||
ValueSource,
|
ValueSource,
|
||||||
)
|
)
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import atomic_write_json, get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -59,21 +59,14 @@ class ValueSourceStore:
|
|||||||
|
|
||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
try:
|
try:
|
||||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
sources_dict = {
|
|
||||||
sid: source.to_dict()
|
|
||||||
for sid, source in self._sources.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"value_sources": sources_dict,
|
"value_sources": {
|
||||||
|
sid: source.to_dict()
|
||||||
|
for sid, source in self._sources.items()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
atomic_write_json(self.file_path, data)
|
||||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save value sources to {self.file_path}: {e}")
|
logger.error(f"Failed to save value sources to {self.file_path}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -34,6 +34,9 @@
|
|||||||
<button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme">
|
<button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme">
|
||||||
<span id="theme-icon">🌙</span>
|
<span id="theme-icon">🌙</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="search-toggle" onclick="openSettingsModal()" data-i18n-title="settings.title" title="Settings">
|
||||||
|
⚙️
|
||||||
|
</button>
|
||||||
<select id="locale-select" onchange="changeLocale()" data-i18n-title="locale.change" title="Change language" style="padding: 4px 8px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-color); color: var(--text-color); font-size: 0.8rem; cursor: pointer;">
|
<select id="locale-select" onchange="changeLocale()" data-i18n-title="locale.change" title="Change language" style="padding: 4px 8px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-color); color: var(--text-color); font-size: 0.8rem; cursor: pointer;">
|
||||||
<option value="en">English</option>
|
<option value="en">English</option>
|
||||||
<option value="ru">Русский</option>
|
<option value="ru">Русский</option>
|
||||||
@@ -128,6 +131,7 @@
|
|||||||
{% include 'modals/test-audio-template.html' %}
|
{% include 'modals/test-audio-template.html' %}
|
||||||
{% include 'modals/value-source-editor.html' %}
|
{% include 'modals/value-source-editor.html' %}
|
||||||
{% include 'modals/test-value-source.html' %}
|
{% include 'modals/test-value-source.html' %}
|
||||||
|
{% include 'modals/settings.html' %}
|
||||||
|
|
||||||
{% include 'partials/tutorial-overlay.html' %}
|
{% include 'partials/tutorial-overlay.html' %}
|
||||||
{% include 'partials/image-lightbox.html' %}
|
{% include 'partials/image-lightbox.html' %}
|
||||||
|
|||||||
33
server/src/wled_controller/templates/modals/settings.html
Normal file
33
server/src/wled_controller/templates/modals/settings.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!-- Settings Modal -->
|
||||||
|
<div id="settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title">
|
||||||
|
<div class="modal-content" style="max-width: 450px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="settings-modal-title" data-i18n="settings.title">Settings</h2>
|
||||||
|
<button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- Backup section -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="settings.backup.label">Backup Configuration</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="settings.backup.hint">Download all configuration (devices, targets, streams, templates, profiles) as a single JSON file.</small>
|
||||||
|
<button class="btn btn-primary" onclick="downloadBackup()" style="width:100%" data-i18n="settings.backup.button">Download Backup</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restore section -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="settings.restore.label">Restore Configuration</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="settings.restore.hint">Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.</small>
|
||||||
|
<input type="file" id="settings-restore-input" accept=".json" style="display:none" onchange="handleRestoreFileSelected(this)">
|
||||||
|
<button class="btn btn-danger" onclick="document.getElementById('settings-restore-input').click()" style="width:100%" data-i18n="settings.restore.button">Restore from Backup</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settings-error" class="error-message" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Utility functions and helpers."""
|
"""Utility functions and helpers."""
|
||||||
|
|
||||||
|
from .file_ops import atomic_write_json
|
||||||
from .logger import setup_logging, get_logger
|
from .logger import setup_logging, get_logger
|
||||||
from .monitor_names import get_monitor_names, get_monitor_name, get_monitor_refresh_rates
|
from .monitor_names import get_monitor_names, get_monitor_name, get_monitor_refresh_rates
|
||||||
from .timer import high_resolution_timer
|
from .timer import high_resolution_timer
|
||||||
|
|
||||||
__all__ = ["setup_logging", "get_logger", "get_monitor_names", "get_monitor_name", "get_monitor_refresh_rates", "high_resolution_timer"]
|
__all__ = ["atomic_write_json", "setup_logging", "get_logger", "get_monitor_names", "get_monitor_name", "get_monitor_refresh_rates", "high_resolution_timer"]
|
||||||
|
|||||||
34
server/src/wled_controller/utils/file_ops.py
Normal file
34
server/src/wled_controller/utils/file_ops.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Atomic file write utilities."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_json(file_path: Path, data: dict, indent: int = 2) -> None:
|
||||||
|
"""Write JSON data to file atomically via temp file + rename.
|
||||||
|
|
||||||
|
Prevents data corruption if the process crashes or loses power
|
||||||
|
mid-write. The rename operation is atomic on most filesystems.
|
||||||
|
"""
|
||||||
|
file_path = Path(file_path)
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Write to a temp file in the same directory (same filesystem for atomic rename)
|
||||||
|
fd, tmp_path = tempfile.mkstemp(
|
||||||
|
dir=file_path.parent,
|
||||||
|
prefix=f".{file_path.stem}_",
|
||||||
|
suffix=".tmp",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=indent, ensure_ascii=False)
|
||||||
|
os.replace(tmp_path, file_path)
|
||||||
|
except BaseException:
|
||||||
|
# Clean up temp file on any error
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
Reference in New Issue
Block a user