Compare commits

..

2 Commits

Author SHA1 Message Date
f8656b72a6 Add configuration backup/restore with settings modal
Backend: GET /api/v1/system/backup bundles all 11 store JSON files into a
single downloadable backup with metadata envelope. POST /api/v1/system/restore
validates and writes stores atomically, then schedules a delayed server restart
via detached restart.ps1 subprocess.

Frontend: Settings modal (gear button in header) with Download Backup and
Restore from Backup buttons. Restore shows confirm dialog, uploads via
multipart FormData, then displays fullscreen restart overlay that polls
/health until the server comes back and reloads the page.

Locales: en, ru, zh translations for all settings.* keys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:23:18 +03:00
9cfe628cc5 Codebase review fixes: stability, performance, quality improvements
Stability: Add outer try/except/finally with _running=False cleanup to all 6
processing loop methods (live, color_strip, effect, audio, composite, mapped).
Add exponential backoff on consecutive capture errors in live_stream. Move
audio stream.stop() outside lock scope.

Performance: Replace per-pixel Python loop with np.array().tobytes() in
ddp_client. Vectorize pixelate filter with cv2.resize down+up. Vectorize
gradient rendering with np.searchsorted.

Frontend: Add lockBody/unlockBody re-entrancy counter. Add {once:true} to
fetchWithAuth abort listener. Null ws.onclose before ws.close() in LED preview.

Backend: Remove auth token prefix from log messages. Add atomic_write_json
helper (tempfile + os.replace) and update all 10 stores. Add name uniqueness
checks to all update methods. Fix DELETE status codes to 204 in audio_sources
and value_sources. Fix get_source() silent bug in color_strip_sources.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:23:04 +03:00
39 changed files with 1313 additions and 786 deletions

54
CODEBASE_REVIEW.md Normal file
View 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.

View File

@@ -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",

View File

@@ -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

View File

@@ -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))

View File

@@ -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):

View File

@@ -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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -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))

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 ─────────────────────────────────────────

View File

@@ -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

View File

@@ -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

View File

@@ -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 ─────────────────────────────────────────────────────────

View File

@@ -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):

View File

@@ -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

View File

@@ -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 ───

View File

@@ -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 {

View File

@@ -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) {

View 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);
}

View File

@@ -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];
} }

View File

@@ -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"
} }

View File

@@ -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": "Закрыть"
} }

View File

@@ -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": "关闭"
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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">
&#x2699;&#xFE0F;
</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' %}

View 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">&#x2715;</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>

View File

@@ -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"]

View 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