From cadef971e79109282e5f799a9dad1c484db8029b Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 26 Feb 2026 20:22:58 +0300 Subject: [PATCH] Add adaptive FPS and honest device reachability during streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DDP uses fire-and-forget UDP, so when a WiFi device becomes overwhelmed by sustained traffic, sends appear successful while the device is actually unreachable. This adds: - HTTP liveness probe (GET /json/info, 2s timeout) every 10s during streaming, exposed as device_streaming_reachable in target state - Adaptive FPS (opt-in): exponential backoff when device is unreachable, gradual recovery when it stabilizes — finds sustainable send rate - Honest health checks: removed the lie that forced device_online=true during streaming; now runs actual health checks regardless - Target editor toggle, FPS display shows effective rate when throttled, health dot reflects streaming reachability, red highlight when unreachable - Auto-backup scheduling support in settings modal Co-Authored-By: Claude Opus 4.6 --- .../src/wled_controller/api/dependencies.py | 13 +- .../api/routes/picture_targets.py | 4 + .../src/wled_controller/api/routes/system.py | 94 +++++++- .../api/schemas/picture_targets.py | 5 + .../src/wled_controller/api/schemas/system.py | 35 +++ .../wled_controller/core/backup/__init__.py | 0 .../core/backup/auto_backup.py | 220 ++++++++++++++++++ .../core/processing/processor_manager.py | 8 +- .../core/processing/target_processor.py | 3 + .../core/processing/wled_target_processor.py | 115 ++++++++- server/src/wled_controller/main.py | 20 ++ .../src/wled_controller/static/css/cards.css | 4 + server/src/wled_controller/static/js/app.js | 7 +- .../static/js/features/dashboard.js | 22 +- .../static/js/features/settings.js | 163 +++++++++++++ .../static/js/features/targets.js | 35 ++- .../wled_controller/static/locales/en.json | 25 +- .../wled_controller/static/locales/ru.json | 25 +- .../wled_controller/static/locales/zh.json | 25 +- .../storage/picture_target_store.py | 4 + .../storage/wled_picture_target.py | 9 +- .../templates/modals/settings.html | 46 ++++ .../templates/modals/target-editor.html | 12 + 23 files changed, 873 insertions(+), 21 deletions(-) create mode 100644 server/src/wled_controller/core/backup/__init__.py create mode 100644 server/src/wled_controller/core/backup/auto_backup.py diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index 97e8a32..0bdad2d 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -13,8 +13,10 @@ from wled_controller.storage.audio_template_store import AudioTemplateStore from wled_controller.storage.value_source_store import ValueSourceStore from wled_controller.storage.profile_store import ProfileStore from wled_controller.core.profiles.profile_engine import ProfileEngine +from wled_controller.core.backup.auto_backup import AutoBackupEngine # Global instances (initialized in main.py) +_auto_backup_engine: AutoBackupEngine | None = None _device_store: DeviceStore | None = None _template_store: TemplateStore | None = None _pp_template_store: PostprocessingTemplateStore | None = None @@ -121,6 +123,13 @@ def get_profile_engine() -> ProfileEngine: return _profile_engine +def get_auto_backup_engine() -> AutoBackupEngine: + """Get auto-backup engine dependency.""" + if _auto_backup_engine is None: + raise RuntimeError("Auto-backup engine not initialized") + return _auto_backup_engine + + def init_dependencies( device_store: DeviceStore, template_store: TemplateStore, @@ -135,12 +144,13 @@ def init_dependencies( value_source_store: ValueSourceStore | None = None, profile_store: ProfileStore | None = None, profile_engine: ProfileEngine | None = None, + auto_backup_engine: AutoBackupEngine | None = None, ): """Initialize global dependencies.""" global _device_store, _template_store, _processor_manager global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store global _color_strip_store, _audio_source_store, _audio_template_store - global _value_source_store, _profile_store, _profile_engine + global _value_source_store, _profile_store, _profile_engine, _auto_backup_engine _device_store = device_store _template_store = template_store _processor_manager = processor_manager @@ -154,3 +164,4 @@ def init_dependencies( _value_source_store = value_source_store _profile_store = profile_store _profile_engine = profile_engine + _auto_backup_engine = auto_backup_engine diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index bf48de4..c9e4c0d 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -101,6 +101,7 @@ def _target_to_response(target) -> PictureTargetResponse: keepalive_interval=target.keepalive_interval, state_check_interval=target.state_check_interval, min_brightness_threshold=target.min_brightness_threshold, + adaptive_fps=target.adaptive_fps, description=target.description, auto_start=target.auto_start, created_at=target.created_at, @@ -161,6 +162,7 @@ async def create_target( keepalive_interval=data.keepalive_interval, state_check_interval=data.state_check_interval, min_brightness_threshold=data.min_brightness_threshold, + adaptive_fps=data.adaptive_fps, picture_source_id=data.picture_source_id, key_colors_settings=kc_settings, description=data.description, @@ -279,6 +281,7 @@ async def update_target( keepalive_interval=data.keepalive_interval, state_check_interval=data.state_check_interval, min_brightness_threshold=data.min_brightness_threshold, + adaptive_fps=data.adaptive_fps, key_colors_settings=kc_settings, description=data.description, auto_start=data.auto_start, @@ -299,6 +302,7 @@ async def update_target( data.keepalive_interval is not None or data.state_check_interval is not None or data.min_brightness_threshold is not None or + data.adaptive_fps is not None or data.key_colors_settings is not None), css_changed=data.color_strip_source_id is not None, device_changed=data.device_id is not None, diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index 3f91cd5..e19b57a 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -16,8 +16,12 @@ from pydantic import BaseModel from wled_controller import __version__ from wled_controller.api.auth import AuthRequired -from wled_controller.api.dependencies import get_processor_manager +from wled_controller.api.dependencies import get_auto_backup_engine, get_processor_manager from wled_controller.api.schemas.system import ( + AutoBackupSettings, + AutoBackupStatusResponse, + BackupFileInfo, + BackupListResponse, DisplayInfo, DisplayListResponse, GpuInfo, @@ -27,6 +31,7 @@ from wled_controller.api.schemas.system import ( RestoreResponse, VersionResponse, ) +from wled_controller.core.backup.auto_backup import AutoBackupEngine from wled_controller.config import get_config from wled_controller.core.capture.screen_capture import get_available_displays from wled_controller.utils import atomic_write_json, get_logger @@ -348,6 +353,93 @@ async def restore_config( ) +# --------------------------------------------------------------------------- +# Auto-backup settings & saved backups +# --------------------------------------------------------------------------- + + +@router.get( + "/api/v1/system/auto-backup/settings", + response_model=AutoBackupStatusResponse, + tags=["System"], +) +async def get_auto_backup_settings( + _: AuthRequired, + engine: AutoBackupEngine = Depends(get_auto_backup_engine), +): + """Get auto-backup settings and status.""" + return engine.get_settings() + + +@router.put( + "/api/v1/system/auto-backup/settings", + response_model=AutoBackupStatusResponse, + tags=["System"], +) +async def update_auto_backup_settings( + _: AuthRequired, + body: AutoBackupSettings, + engine: AutoBackupEngine = Depends(get_auto_backup_engine), +): + """Update auto-backup settings (enable/disable, interval, max backups).""" + return await engine.update_settings( + enabled=body.enabled, + interval_hours=body.interval_hours, + max_backups=body.max_backups, + ) + + +@router.get( + "/api/v1/system/backups", + response_model=BackupListResponse, + tags=["System"], +) +async def list_backups( + _: AuthRequired, + engine: AutoBackupEngine = Depends(get_auto_backup_engine), +): + """List all saved backup files.""" + backups = engine.list_backups() + return BackupListResponse( + backups=[BackupFileInfo(**b) for b in backups], + count=len(backups), + ) + + +@router.get("/api/v1/system/backups/{filename}", tags=["System"]) +def download_saved_backup( + filename: str, + _: AuthRequired, + engine: AutoBackupEngine = Depends(get_auto_backup_engine), +): + """Download a specific saved backup file.""" + try: + path = engine.get_backup_path(filename) + except (ValueError, FileNotFoundError) as e: + raise HTTPException(status_code=404, detail=str(e)) + + content = path.read_bytes() + return StreamingResponse( + io.BytesIO(content), + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.delete("/api/v1/system/backups/{filename}", tags=["System"]) +async def delete_saved_backup( + filename: str, + _: AuthRequired, + engine: AutoBackupEngine = Depends(get_auto_backup_engine), +): + """Delete a specific saved backup file.""" + try: + engine.delete_backup(filename) + except (ValueError, FileNotFoundError) as e: + raise HTTPException(status_code=404, detail=str(e)) + return {"status": "deleted", "filename": filename} + + # --------------------------------------------------------------------------- # ADB helpers (for Android / scrcpy engine) # --------------------------------------------------------------------------- diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index c6b820c..4095842 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -59,6 +59,7 @@ class PictureTargetCreate(BaseModel): keepalive_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0) state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600) min_brightness_threshold: int = Field(default=0, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off") + adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive") # KC target fields picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)") @@ -78,6 +79,7 @@ class PictureTargetUpdate(BaseModel): keepalive_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0) state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600) min_brightness_threshold: Optional[int] = Field(None, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off") + adaptive_fps: Optional[bool] = Field(None, description="Auto-reduce FPS when device is unresponsive") # KC target fields picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)") @@ -99,6 +101,7 @@ class PictureTargetResponse(BaseModel): keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)") state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)") min_brightness_threshold: int = Field(default=0, description="Min brightness threshold (0=disabled)") + adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive") # KC target fields picture_source_id: str = Field(default="", description="Picture source ID (key_colors)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings") @@ -152,6 +155,8 @@ class TargetProcessingState(BaseModel): device_fps: Optional[int] = Field(None, description="Device-reported FPS (WLED internal refresh rate)") device_last_checked: Optional[datetime] = Field(None, description="Last health check time") device_error: Optional[str] = Field(None, description="Last health check error") + device_streaming_reachable: Optional[bool] = Field(None, description="Device reachable during streaming (HTTP probe)") + fps_effective: Optional[int] = Field(None, description="Effective FPS after adaptive reduction") class TargetMetricsResponse(BaseModel): diff --git a/server/src/wled_controller/api/schemas/system.py b/server/src/wled_controller/api/schemas/system.py index 24fe62a..2b10265 100644 --- a/server/src/wled_controller/api/schemas/system.py +++ b/server/src/wled_controller/api/schemas/system.py @@ -79,3 +79,38 @@ class RestoreResponse(BaseModel): 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") + + +# ─── Auto-backup schemas ────────────────────────────────────── + +class AutoBackupSettings(BaseModel): + """Settings for automatic backup.""" + + enabled: bool = Field(description="Whether auto-backup is enabled") + interval_hours: float = Field(ge=0.5, le=168, description="Backup interval in hours") + max_backups: int = Field(ge=1, le=100, description="Maximum number of backup files to keep") + + +class AutoBackupStatusResponse(BaseModel): + """Auto-backup settings plus runtime status.""" + + enabled: bool + interval_hours: float + max_backups: int + last_backup_time: str | None = None + next_backup_time: str | None = None + + +class BackupFileInfo(BaseModel): + """Information about a saved backup file.""" + + filename: str + size_bytes: int + created_at: str + + +class BackupListResponse(BaseModel): + """List of saved backup files.""" + + backups: List[BackupFileInfo] + count: int diff --git a/server/src/wled_controller/core/backup/__init__.py b/server/src/wled_controller/core/backup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/src/wled_controller/core/backup/auto_backup.py b/server/src/wled_controller/core/backup/auto_backup.py new file mode 100644 index 0000000..acf618d --- /dev/null +++ b/server/src/wled_controller/core/backup/auto_backup.py @@ -0,0 +1,220 @@ +"""Auto-backup engine — periodic background backups of all configuration stores.""" + +import asyncio +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from wled_controller import __version__ +from wled_controller.utils import atomic_write_json, get_logger + +logger = get_logger(__name__) + +DEFAULT_SETTINGS = { + "enabled": False, + "interval_hours": 24, + "max_backups": 10, +} + + +class AutoBackupEngine: + """Creates periodic backups of all configuration stores.""" + + def __init__( + self, + settings_path: Path, + backup_dir: Path, + store_map: Dict[str, str], + storage_config: Any, + ): + self._settings_path = Path(settings_path) + self._backup_dir = Path(backup_dir) + self._store_map = store_map + self._storage_config = storage_config + self._task: Optional[asyncio.Task] = None + self._last_backup_time: Optional[datetime] = None + + self._settings = self._load_settings() + self._backup_dir.mkdir(parents=True, exist_ok=True) + + # ─── Settings persistence ────────────────────────────────── + + def _load_settings(self) -> dict: + if self._settings_path.exists(): + try: + with open(self._settings_path, "r", encoding="utf-8") as f: + data = json.load(f) + return {**DEFAULT_SETTINGS, **data} + except Exception as e: + logger.warning(f"Failed to load auto-backup settings: {e}") + return dict(DEFAULT_SETTINGS) + + def _save_settings(self) -> None: + atomic_write_json(self._settings_path, { + "enabled": self._settings["enabled"], + "interval_hours": self._settings["interval_hours"], + "max_backups": self._settings["max_backups"], + }) + + # ─── Lifecycle ───────────────────────────────────────────── + + async def start(self) -> None: + if self._settings["enabled"]: + self._start_loop() + logger.info( + f"Auto-backup engine started (every {self._settings['interval_hours']}h, " + f"max {self._settings['max_backups']})" + ) + else: + logger.info("Auto-backup engine initialized (disabled)") + + async def stop(self) -> None: + self._cancel_loop() + logger.info("Auto-backup engine stopped") + + def _start_loop(self) -> None: + self._cancel_loop() + self._task = asyncio.create_task(self._backup_loop()) + + def _cancel_loop(self) -> None: + if self._task is not None: + self._task.cancel() + self._task = None + + async def _backup_loop(self) -> None: + try: + # Perform first backup immediately on start + await self._perform_backup() + self._prune_old_backups() + + interval_secs = self._settings["interval_hours"] * 3600 + while True: + await asyncio.sleep(interval_secs) + try: + await self._perform_backup() + self._prune_old_backups() + except Exception as e: + logger.error(f"Auto-backup failed: {e}", exc_info=True) + except asyncio.CancelledError: + pass + + # ─── Backup operations ───────────────────────────────────── + + async def _perform_backup(self) -> None: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._perform_backup_sync) + + def _perform_backup_sync(self) -> None: + stores = {} + for store_key, config_attr in self._store_map.items(): + file_path = Path(getattr(self._storage_config, 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] = {} + + now = datetime.now(timezone.utc) + backup = { + "meta": { + "format": "ledgrab-backup", + "format_version": 1, + "app_version": __version__, + "created_at": now.isoformat(), + "store_count": len(stores), + "auto_backup": True, + }, + "stores": stores, + } + + timestamp = now.strftime("%Y-%m-%dT%H%M%S") + filename = f"ledgrab-autobackup-{timestamp}.json" + file_path = self._backup_dir / filename + + content = json.dumps(backup, indent=2, ensure_ascii=False) + file_path.write_text(content, encoding="utf-8") + + self._last_backup_time = now + logger.info(f"Auto-backup created: {filename}") + + def _prune_old_backups(self) -> None: + max_backups = self._settings["max_backups"] + files = sorted(self._backup_dir.glob("*.json"), key=lambda p: p.stat().st_mtime) + excess = len(files) - max_backups + if excess > 0: + for f in files[:excess]: + try: + f.unlink() + logger.info(f"Pruned old backup: {f.name}") + except Exception as e: + logger.warning(f"Failed to prune {f.name}: {e}") + + # ─── Public API ──────────────────────────────────────────── + + def get_settings(self) -> dict: + next_backup = None + if self._settings["enabled"] and self._last_backup_time: + from datetime import timedelta + next_backup = ( + self._last_backup_time + timedelta(hours=self._settings["interval_hours"]) + ).isoformat() + + return { + "enabled": self._settings["enabled"], + "interval_hours": self._settings["interval_hours"], + "max_backups": self._settings["max_backups"], + "last_backup_time": self._last_backup_time.isoformat() if self._last_backup_time else None, + "next_backup_time": next_backup, + } + + async def update_settings(self, enabled: bool, interval_hours: float, max_backups: int) -> dict: + self._settings["enabled"] = enabled + self._settings["interval_hours"] = interval_hours + self._settings["max_backups"] = max_backups + self._save_settings() + + # Restart or stop the loop + if enabled: + self._start_loop() + logger.info( + f"Auto-backup enabled (every {interval_hours}h, max {max_backups})" + ) + else: + self._cancel_loop() + logger.info("Auto-backup disabled") + + # Prune if max_backups was reduced + self._prune_old_backups() + + return self.get_settings() + + def list_backups(self) -> List[dict]: + backups = [] + for f in sorted(self._backup_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True): + stat = f.stat() + backups.append({ + "filename": f.name, + "size_bytes": stat.st_size, + "created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + }) + return backups + + def delete_backup(self, filename: str) -> None: + # Validate filename to prevent path traversal + if os.sep in filename or "/" in filename or ".." in filename: + raise ValueError("Invalid filename") + target = self._backup_dir / filename + if not target.exists(): + raise FileNotFoundError(f"Backup not found: {filename}") + target.unlink() + logger.info(f"Deleted backup: {filename}") + + def get_backup_path(self, filename: str) -> Path: + if os.sep in filename or "/" in filename or ".." in filename: + raise ValueError("Invalid filename") + target = self._backup_dir / filename + if not target.exists(): + raise FileNotFoundError(f"Backup not found: {filename}") + return target diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 9b1a7c1..ca255ca 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -322,6 +322,7 @@ class ProcessorManager: state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, brightness_value_source_id: str = "", min_brightness_threshold: int = 0, + adaptive_fps: bool = False, ): """Register a WLED target processor.""" if target_id in self._processors: @@ -338,6 +339,7 @@ class ProcessorManager: state_check_interval=state_check_interval, brightness_value_source_id=brightness_value_source_id, min_brightness_threshold=min_brightness_threshold, + adaptive_fps=adaptive_fps, ctx=self._build_context(), ) self._processors[target_id] = proc @@ -834,11 +836,7 @@ class ProcessorManager: try: while self._health_monitoring_active: - if not self._device_is_processing(device_id): - await self._check_device_health(device_id) - else: - if state.health: - state.health.online = True + await self._check_device_health(device_id) await asyncio.sleep(check_interval) except asyncio.CancelledError: pass diff --git a/server/src/wled_controller/core/processing/target_processor.py b/server/src/wled_controller/core/processing/target_processor.py index 8ab6c07..5604677 100644 --- a/server/src/wled_controller/core/processing/target_processor.py +++ b/server/src/wled_controller/core/processing/target_processor.py @@ -56,6 +56,9 @@ class ProcessingMetrics: # KC targets timing_calc_colors_ms: float = 0.0 timing_broadcast_ms: float = 0.0 + # Streaming liveness (HTTP probe during DDP) + device_streaming_reachable: Optional[bool] = None + fps_effective: int = 0 @dataclass diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index 71ff622..f792f6c 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -8,6 +8,7 @@ import time from datetime import datetime from typing import Optional +import httpx import numpy as np from wled_controller.core.devices.led_client import LEDClient, create_led_client, get_device_capabilities @@ -37,6 +38,7 @@ class WledTargetProcessor(TargetProcessor): state_check_interval: int = 30, brightness_value_source_id: str = "", min_brightness_threshold: int = 0, + adaptive_fps: bool = False, ctx: TargetContext = None, ): super().__init__(target_id, ctx) @@ -47,6 +49,11 @@ class WledTargetProcessor(TargetProcessor): self._css_id = color_strip_source_id self._brightness_vs_id = brightness_value_source_id self._min_brightness_threshold = min_brightness_threshold + self._adaptive_fps = adaptive_fps + + # Adaptive FPS / liveness probe runtime state + self._effective_fps: int = self._target_fps + self._device_reachable: Optional[bool] = None # None = not yet probed # Runtime state (populated on start) self._led_client: Optional[LEDClient] = None @@ -60,6 +67,8 @@ class WledTargetProcessor(TargetProcessor): # LED preview WebSocket clients self._preview_clients: list = [] + self._last_preview_colors: np.ndarray | None = None + self._last_preview_brightness: int = 255 # ----- Properties ----- @@ -205,6 +214,7 @@ class WledTargetProcessor(TargetProcessor): if isinstance(settings, dict): if "fps" in settings: self._target_fps = settings["fps"] if settings["fps"] > 0 else 30 + self._effective_fps = self._target_fps # reset adaptive css_manager = self._ctx.color_strip_stream_manager if css_manager and self._is_running and self._css_id: css_manager.notify_target_fps(self._css_id, self._target_id, self._target_fps) @@ -214,6 +224,10 @@ class WledTargetProcessor(TargetProcessor): self._state_check_interval = settings["state_check_interval"] if "min_brightness_threshold" in settings: self._min_brightness_threshold = settings["min_brightness_threshold"] + if "adaptive_fps" in settings: + self._adaptive_fps = settings["adaptive_fps"] + if not self._adaptive_fps: + self._effective_fps = self._target_fps logger.info(f"Updated settings for target {self._target_id}") def update_device(self, device_id: str) -> None: @@ -285,6 +299,14 @@ class WledTargetProcessor(TargetProcessor): logger.info(f"Hot-swapped brightness VS for {self._target_id}: {old_vs_id} -> {vs_id}") + async def _probe_device(self, device_url: str, client: httpx.AsyncClient) -> bool: + """HTTP liveness probe — lightweight GET to check if device is reachable.""" + try: + resp = await client.get(f"{device_url}/json/info") + return resp.status_code == 200 + except Exception: + return False + def get_display_index(self) -> Optional[int]: """Display index being captured, from the active stream.""" if self._resolved_display_index is not None: @@ -349,6 +371,8 @@ class WledTargetProcessor(TargetProcessor): "needs_keepalive": self._needs_keepalive, "last_update": metrics.last_update, "errors": [metrics.last_error] if metrics.last_error else [], + "device_streaming_reachable": self._device_reachable if self._is_running else None, + "fps_effective": self._effective_fps if self._is_running else None, } def get_metrics(self) -> dict: @@ -432,6 +456,17 @@ class WledTargetProcessor(TargetProcessor): def add_led_preview_client(self, ws) -> None: self._preview_clients.append(ws) + # Send last known frame immediately so late joiners see current state + if self._last_preview_colors is not None: + data = bytes([self._last_preview_brightness]) + self._last_preview_colors.astype(np.uint8).tobytes() + asyncio.ensure_future(self._send_preview_to(ws, data)) + + @staticmethod + async def _send_preview_to(ws, data: bytes) -> None: + try: + await ws.send_bytes(data) + except Exception: + pass def remove_led_preview_client(self, ws) -> None: if ws in self._preview_clients: @@ -536,9 +571,22 @@ class WledTargetProcessor(TargetProcessor): _diag_device_info: Optional[DeviceInfo] = None _diag_device_info_age = 0 + # --- Liveness probe + adaptive FPS --- + _device_url = _init_device_info.device_url if _init_device_info else "" + _probe_enabled = _device_url.startswith("http") + _probe_interval = 10.0 # seconds between probes + _last_probe_time = 0.0 # force first probe soon (after 10s) + _probe_task: Optional[asyncio.Task] = None + _probe_client: Optional[httpx.AsyncClient] = None + if _probe_enabled: + _probe_client = httpx.AsyncClient(timeout=httpx.Timeout(2.0)) + self._effective_fps = self._target_fps + self._device_reachable = None + logger.info( f"Processing loop started for target {self._target_id} " - f"(css={self._css_id}, {_total_leds} LEDs, fps={self._target_fps})" + f"(css={self._css_id}, {_total_leds} LEDs, fps={self._target_fps}" + f"{', adaptive' if self._adaptive_fps else ''})" ) next_frame_time = time.perf_counter() @@ -548,7 +596,61 @@ class WledTargetProcessor(TargetProcessor): while self._is_running: loop_start = now = time.perf_counter() target_fps = self._target_fps if self._target_fps > 0 else 30 - frame_time = 1.0 / target_fps + + # --- Liveness probe --- + # Collect result as soon as it's done (every iteration) + if _probe_task is not None and _probe_task.done(): + try: + reachable = _probe_task.result() + except Exception: + reachable = False + prev_reachable = self._device_reachable + self._device_reachable = reachable + self._metrics.device_streaming_reachable = reachable + _probe_task = None + + if self._adaptive_fps: + if not reachable: + # Backoff: halve effective FPS + old_eff = self._effective_fps + self._effective_fps = max(1, self._effective_fps // 2) + if old_eff != self._effective_fps: + logger.warning( + f"[ADAPTIVE] {self._target_id} device unreachable, " + f"FPS {old_eff} → {self._effective_fps}" + ) + next_frame_time = time.perf_counter() + else: + # Recovery: gradually increase + if self._effective_fps < target_fps: + step = max(1, target_fps // 8) + old_eff = self._effective_fps + self._effective_fps = min(target_fps, self._effective_fps + step) + if old_eff != self._effective_fps: + logger.info( + f"[ADAPTIVE] {self._target_id} device reachable, " + f"FPS {old_eff} → {self._effective_fps}" + ) + next_frame_time = time.perf_counter() + + if prev_reachable != reachable: + logger.info( + f"[PROBE] {self._target_id} device " + f"{'reachable' if reachable else 'UNREACHABLE'}" + ) + + # Fire new probe every _probe_interval seconds + if _probe_enabled and _probe_task is None and (now - _last_probe_time) >= _probe_interval: + if _probe_client is not None: + _last_probe_time = now + _probe_task = asyncio.create_task( + self._probe_device(_device_url, _probe_client) + ) + + # Use effective FPS for frame timing + effective_fps = self._effective_fps if self._adaptive_fps else target_fps + self._metrics.fps_effective = effective_fps + frame_time = 1.0 / effective_fps keepalive_interval = self._keepalive_interval # Detect hot-swapped CSS stream @@ -640,6 +742,8 @@ class WledTargetProcessor(TargetProcessor): # Fit to device LED count and apply brightness device_colors = self._fit_to_device(frame, _total_leds) send_colors = _cached_brightness(device_colors, cur_brightness) + self._last_preview_colors = send_colors + self._last_preview_brightness = cur_brightness # Send to LED device if not self._is_running or self._led_client is None: @@ -752,4 +856,11 @@ class WledTargetProcessor(TargetProcessor): self._is_running = False raise finally: + # Clean up probe client + if _probe_client is not None: + await _probe_client.aclose() + if _probe_task is not None and not _probe_task.done(): + _probe_task.cancel() + self._device_reachable = None + self._metrics.device_streaming_reachable = None logger.info(f"Processing loop ended for target {self._target_id}") diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 4d774b8..a68ff31 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -29,6 +29,8 @@ import wled_controller.core.audio # noqa: F401 — trigger engine auto-registra from wled_controller.storage.value_source_store import ValueSourceStore from wled_controller.storage.profile_store import ProfileStore from wled_controller.core.profiles.profile_engine import ProfileEngine +from wled_controller.core.backup.auto_backup import AutoBackupEngine +from wled_controller.api.routes.system import STORE_MAP from wled_controller.utils import setup_logging, get_logger # Initialize logging @@ -101,6 +103,14 @@ async def lifespan(app: FastAPI): # Create profile engine (needs processor_manager) profile_engine = ProfileEngine(profile_store, processor_manager) + # Create auto-backup engine + auto_backup_engine = AutoBackupEngine( + settings_path=Path("data/auto_backup_settings.json"), + backup_dir=Path("data/backups"), + store_map=STORE_MAP, + storage_config=config.storage, + ) + # Initialize API dependencies init_dependencies( device_store, template_store, processor_manager, @@ -114,6 +124,7 @@ async def lifespan(app: FastAPI): value_source_store=value_source_store, profile_store=profile_store, profile_engine=profile_engine, + auto_backup_engine=auto_backup_engine, ) # Register devices in processor manager for health monitoring @@ -154,6 +165,9 @@ async def lifespan(app: FastAPI): # Start profile engine (evaluates conditions and auto-starts/stops targets) await profile_engine.start() + # Start auto-backup engine (periodic configuration backups) + await auto_backup_engine.start() + # Auto-start targets with auto_start=True auto_started = 0 for target in targets: @@ -172,6 +186,12 @@ async def lifespan(app: FastAPI): # Shutdown logger.info("Shutting down LED Grab") + # Stop auto-backup engine + try: + await auto_backup_engine.stop() + except Exception as e: + logger.error(f"Error stopping auto-backup engine: {e}") + # Stop profile engine first (deactivates profile-managed targets) try: await profile_engine.stop() diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 9822cd0..bcb34f7 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -549,6 +549,10 @@ ul.section-tip li { color: #4CAF50; } +.fps-unreachable { + color: #ff5252; +} + /* Timing breakdown bar */ .timing-breakdown { margin-top: 8px; diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 29b74a8..9b6c7bd 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -137,6 +137,7 @@ import { navigateToCard } from './core/navigation.js'; import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js'; import { openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected, + saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup, } from './features/settings.js'; // ─── Register all HTML onclick / onchange / onfocus globals ─── @@ -388,11 +389,15 @@ Object.assign(window, { openCommandPalette, closeCommandPalette, - // settings (backup / restore) + // settings (backup / restore / auto-backup) openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected, + saveAutoBackupSettings, + restoreSavedBackup, + downloadSavedBackup, + deleteSavedBackup, }); // ─── Global keyboard shortcuts ─── diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index 7659bc7..b3d2f2f 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -207,20 +207,32 @@ function _updateRunningMetrics(enrichedRunning) { // Update text values (use cached refs, fallback to querySelector) const cached = _metricsElements.get(target.id); const fpsEl = cached?.fps || document.querySelector(`[data-fps-text="${target.id}"]`); - if (fpsEl) fpsEl.innerHTML = `${fpsCurrent}/${fpsTarget}` - + `avg ${fpsActual}`; + if (fpsEl) { + const effFps = state.fps_effective; + const fpsTargetLabel = (effFps != null && effFps < fpsTarget) + ? `${fpsCurrent}/${effFps}↓${fpsTarget}` + : `${fpsCurrent}/${fpsTarget}`; + const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : ''; + fpsEl.innerHTML = `${fpsTargetLabel}` + + `avg ${fpsActual}`; + } const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`); if (errorsEl) errorsEl.textContent = `${errors > 0 ? ICON_WARNING : ICON_OK} ${errors}`; - // Update health dot + // Update health dot — prefer streaming reachability when processing const isLed = target.target_type === 'led' || target.target_type === 'wled'; if (isLed) { const row = cached?.row || document.querySelector(`[data-target-id="${target.id}"]`); if (row) { const dot = row.querySelector('.health-dot'); - if (dot && state.device_last_checked != null) { - dot.className = `health-dot ${state.device_online ? 'health-online' : 'health-offline'}`; + if (dot) { + const streamReachable = state.device_streaming_reachable; + if (state.processing && streamReachable != null) { + dot.className = `health-dot ${streamReachable ? 'health-online' : 'health-offline'}`; + } else if (state.device_last_checked != null) { + dot.className = `health-dot ${state.device_online ? 'health-online' : 'health-offline'}`; + } } } } diff --git a/server/src/wled_controller/static/js/features/settings.js b/server/src/wled_controller/static/js/features/settings.js index 522d2fb..161b5eb 100644 --- a/server/src/wled_controller/static/js/features/settings.js +++ b/server/src/wled_controller/static/js/features/settings.js @@ -14,6 +14,8 @@ const settingsModal = new Modal('settings-modal'); export function openSettingsModal() { document.getElementById('settings-error').style.display = 'none'; settingsModal.open(); + loadAutoBackupSettings(); + loadBackupList(); } export function closeSettingsModal() { @@ -135,3 +137,164 @@ function pollHealth() { // Wait a moment before first check to let the server shut down setTimeout(check, 2000); } + +// ─── Auto-Backup settings ───────────────────────────────── + +export async function loadAutoBackupSettings() { + try { + const resp = await fetchWithAuth('/system/auto-backup/settings'); + if (!resp.ok) return; + const data = await resp.json(); + + document.getElementById('auto-backup-enabled').checked = data.enabled; + document.getElementById('auto-backup-interval').value = String(data.interval_hours); + document.getElementById('auto-backup-max').value = data.max_backups; + + const statusEl = document.getElementById('auto-backup-status'); + if (data.last_backup_time) { + const d = new Date(data.last_backup_time); + statusEl.textContent = t('settings.auto_backup.last_backup') + ': ' + d.toLocaleString(); + } else { + statusEl.textContent = t('settings.auto_backup.last_backup') + ': ' + t('settings.auto_backup.never'); + } + } catch (err) { + console.error('Failed to load auto-backup settings:', err); + } +} + +export async function saveAutoBackupSettings() { + const enabled = document.getElementById('auto-backup-enabled').checked; + const interval_hours = parseFloat(document.getElementById('auto-backup-interval').value); + const max_backups = parseInt(document.getElementById('auto-backup-max').value, 10); + + try { + const resp = await fetchWithAuth('/system/auto-backup/settings', { + method: 'PUT', + body: JSON.stringify({ enabled, interval_hours, max_backups }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + showToast(t('settings.auto_backup.saved'), 'success'); + loadAutoBackupSettings(); + loadBackupList(); + } catch (err) { + console.error('Failed to save auto-backup settings:', err); + showToast(t('settings.auto_backup.save_error') + ': ' + err.message, 'error'); + } +} + +// ─── Saved backup list ──────────────────────────────────── + +export async function loadBackupList() { + const container = document.getElementById('saved-backups-list'); + try { + const resp = await fetchWithAuth('/system/backups'); + if (!resp.ok) return; + const data = await resp.json(); + + if (data.count === 0) { + container.innerHTML = `
${t('settings.saved_backups.empty')}
`; + return; + } + + container.innerHTML = data.backups.map(b => { + const sizeKB = (b.size_bytes / 1024).toFixed(1); + const date = new Date(b.created_at).toLocaleString(); + return `
+
+ ${date} + ${sizeKB} KB +
+ + + +
`; + }).join(''); + } catch (err) { + console.error('Failed to load backup list:', err); + container.innerHTML = ''; + } +} + +export async function downloadSavedBackup(filename) { + try { + const resp = await fetchWithAuth(`/system/backups/${encodeURIComponent(filename)}`, { 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 a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(a.href); + } catch (err) { + console.error('Backup download failed:', err); + showToast(t('settings.backup.error') + ': ' + err.message, 'error'); + } +} + +export async function restoreSavedBackup(filename) { + const confirmed = await showConfirm(t('settings.restore.confirm')); + if (!confirmed) return; + + try { + // Download the backup file from the server + const dlResp = await fetchWithAuth(`/system/backups/${encodeURIComponent(filename)}`, { timeout: 30000 }); + if (!dlResp.ok) { + const err = await dlResp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${dlResp.status}`); + } + const blob = await dlResp.blob(); + + // POST it to the restore endpoint + const formData = new FormData(); + formData.append('file', blob, filename); + + 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 from saved backup failed:', err); + showToast(t('settings.restore.error') + ': ' + err.message, 'error'); + } +} + +export async function deleteSavedBackup(filename) { + const confirmed = await showConfirm(t('settings.saved_backups.delete_confirm')); + if (!confirmed) return; + + try { + const resp = await fetchWithAuth(`/system/backups/${encodeURIComponent(filename)}`, { + method: 'DELETE', + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + loadBackupList(); + } catch (err) { + console.error('Backup delete failed:', err); + showToast(t('settings.saved_backups.delete_error') + ': ' + err.message, 'error'); + } +} diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index ade8aab..44a187d 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -143,6 +143,7 @@ class TargetEditorModal extends Modal { brightness_threshold: document.getElementById('target-editor-brightness-threshold').value, fps: document.getElementById('target-editor-fps').value, keepalive_interval: document.getElementById('target-editor-keepalive-interval').value, + adaptive_fps: document.getElementById('target-editor-adaptive-fps').checked, }; } } @@ -272,6 +273,8 @@ export async function showTargetEditor(targetId = null, cloneData = null) { document.getElementById('target-editor-brightness-threshold').value = thresh; document.getElementById('target-editor-brightness-threshold-value').textContent = thresh; + document.getElementById('target-editor-adaptive-fps').checked = target.adaptive_fps ?? false; + _populateCssDropdown(target.color_strip_source_id || ''); _populateBrightnessVsDropdown(target.brightness_value_source_id || ''); } else if (cloneData) { @@ -290,6 +293,8 @@ export async function showTargetEditor(targetId = null, cloneData = null) { document.getElementById('target-editor-brightness-threshold').value = cloneThresh; document.getElementById('target-editor-brightness-threshold-value').textContent = cloneThresh; + document.getElementById('target-editor-adaptive-fps').checked = cloneData.adaptive_fps ?? false; + _populateCssDropdown(cloneData.color_strip_source_id || ''); _populateBrightnessVsDropdown(cloneData.brightness_value_source_id || ''); } else { @@ -305,6 +310,8 @@ export async function showTargetEditor(targetId = null, cloneData = null) { document.getElementById('target-editor-brightness-threshold').value = 0; document.getElementById('target-editor-brightness-threshold-value').textContent = '0'; + document.getElementById('target-editor-adaptive-fps').checked = false; + _populateCssDropdown(''); _populateBrightnessVsDropdown(''); } @@ -364,6 +371,8 @@ export async function saveTargetEditor() { const brightnessVsId = document.getElementById('target-editor-brightness-vs').value; const minBrightnessThreshold = parseInt(document.getElementById('target-editor-brightness-threshold').value) || 0; + const adaptiveFps = document.getElementById('target-editor-adaptive-fps').checked; + const payload = { name, device_id: deviceId, @@ -372,6 +381,7 @@ export async function saveTargetEditor() { min_brightness_threshold: minBrightnessThreshold, fps, keepalive_interval: standbyInterval, + adaptive_fps: adaptiveFps, }; try { @@ -765,8 +775,29 @@ function _patchTargetMetrics(target) { const metrics = target.metrics || {}; const fps = card.querySelector('[data-tm="fps"]'); - if (fps) fps.innerHTML = `${state.fps_current ?? 0}/${state.fps_target || 0}` - + `avg ${state.fps_actual?.toFixed(1) || '0.0'}`; + if (fps) { + const effFps = state.fps_effective; + const tgtFps = state.fps_target || 0; + const fpsLabel = (effFps != null && effFps < tgtFps) + ? `${state.fps_current ?? 0}/${effFps}↓${tgtFps}` + : `${state.fps_current ?? 0}/${tgtFps}`; + const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : ''; + fps.innerHTML = `${fpsLabel}` + + `avg ${state.fps_actual?.toFixed(1) || '0.0'}`; + } + + // Update health dot to reflect streaming reachability when processing + const healthDot = card.querySelector('.health-dot'); + if (healthDot && state.processing) { + const reachable = state.device_streaming_reachable; + if (reachable === false) { + healthDot.className = 'health-dot health-offline'; + healthDot.title = t('device.health.streaming_unreachable') || 'Unreachable during streaming'; + } else if (reachable === true) { + healthDot.className = 'health-dot health-online'; + healthDot.title = t('device.health.online'); + } + } const timing = card.querySelector('[data-tm="timing"]'); if (timing && state.timing_total_ms != null) timing.innerHTML = _buildLedTimingHTML(state); diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 0c51400..3b53aad 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -169,6 +169,7 @@ "device.metrics.device_fps": "Device refresh rate", "device.health.online": "Online", "device.health.offline": "Offline", + "device.health.streaming_unreachable": "Unreachable during streaming", "device.health.checking": "Checking...", "device.tutorial.start": "Start tutorial", "device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device", @@ -912,6 +913,8 @@ "targets.brightness_vs.none": "None (device brightness)", "targets.min_brightness_threshold": "Min Brightness Threshold:", "targets.min_brightness_threshold.hint": "Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)", + "targets.adaptive_fps": "Adaptive FPS:", + "targets.adaptive_fps.hint": "Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.", "search.open": "Search (Ctrl+K)", "search.placeholder": "Search entities... (Ctrl+K)", @@ -943,5 +946,25 @@ "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" + "settings.button.close": "Close", + + "settings.auto_backup.label": "Auto-Backup", + "settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.", + "settings.auto_backup.enable": "Enable auto-backup", + "settings.auto_backup.interval_label": "Interval", + "settings.auto_backup.max_label": "Max backups", + "settings.auto_backup.save": "Save Settings", + "settings.auto_backup.saved": "Auto-backup settings saved", + "settings.auto_backup.save_error": "Failed to save auto-backup settings", + "settings.auto_backup.last_backup": "Last backup", + "settings.auto_backup.never": "Never", + + "settings.saved_backups.label": "Saved Backups", + "settings.saved_backups.hint": "Auto-backup files stored on the server. Download to save locally, or delete to free space.", + "settings.saved_backups.empty": "No saved backups", + "settings.saved_backups.restore": "Restore", + "settings.saved_backups.download": "Download", + "settings.saved_backups.delete": "Delete", + "settings.saved_backups.delete_confirm": "Delete this backup file?", + "settings.saved_backups.delete_error": "Failed to delete backup" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index df210f3..ba5b58a 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -169,6 +169,7 @@ "device.metrics.device_fps": "Частота обновления устройства", "device.health.online": "Онлайн", "device.health.offline": "Недоступен", + "device.health.streaming_unreachable": "Недоступен во время стриминга", "device.health.checking": "Проверка...", "device.tutorial.start": "Начать обучение", "device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически", @@ -912,6 +913,8 @@ "targets.brightness_vs.none": "Нет (яркость устройства)", "targets.min_brightness_threshold": "Мин. порог яркости:", "targets.min_brightness_threshold.hint": "Если итоговая яркость (яркость пикселей × яркость устройства/источника) ниже этого значения, светодиоды полностью выключаются (0 = отключено)", + "targets.adaptive_fps": "Адаптивный FPS:", + "targets.adaptive_fps.hint": "Автоматически снижает частоту отправки, когда устройство перестаёт отвечать, и постепенно восстанавливает её при стабилизации. Рекомендуется для WiFi-устройств со слабым сигналом.", "search.open": "Поиск (Ctrl+K)", "search.placeholder": "Поиск... (Ctrl+K)", @@ -943,5 +946,25 @@ "settings.restore.error": "Ошибка восстановления", "settings.restore.restarting": "Сервер перезапускается...", "settings.restore.restart_timeout": "Сервер не отвечает. Обновите страницу вручную.", - "settings.button.close": "Закрыть" + "settings.button.close": "Закрыть", + + "settings.auto_backup.label": "Авто-бэкап", + "settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.", + "settings.auto_backup.enable": "Включить авто-бэкап", + "settings.auto_backup.interval_label": "Интервал", + "settings.auto_backup.max_label": "Макс. копий", + "settings.auto_backup.save": "Сохранить настройки", + "settings.auto_backup.saved": "Настройки авто-бэкапа сохранены", + "settings.auto_backup.save_error": "Не удалось сохранить настройки авто-бэкапа", + "settings.auto_backup.last_backup": "Последний бэкап", + "settings.auto_backup.never": "Никогда", + + "settings.saved_backups.label": "Сохранённые копии", + "settings.saved_backups.hint": "Файлы авто-бэкапа на сервере. Скачайте для локального хранения или удалите для освобождения места.", + "settings.saved_backups.empty": "Нет сохранённых копий", + "settings.saved_backups.restore": "Восстановить", + "settings.saved_backups.download": "Скачать", + "settings.saved_backups.delete": "Удалить", + "settings.saved_backups.delete_confirm": "Удалить эту резервную копию?", + "settings.saved_backups.delete_error": "Не удалось удалить копию" } diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index a1f9485..8fb3b6f 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -169,6 +169,7 @@ "device.metrics.device_fps": "设备刷新率", "device.health.online": "在线", "device.health.offline": "离线", + "device.health.streaming_unreachable": "流传输期间不可达", "device.health.checking": "检测中...", "device.tutorial.start": "开始教程", "device.tip.metadata": "设备信息(LED 数量、类型、颜色通道)从设备自动检测", @@ -912,6 +913,8 @@ "targets.brightness_vs.none": "无(设备亮度)", "targets.min_brightness_threshold": "最低亮度阈值:", "targets.min_brightness_threshold.hint": "当有效输出亮度(像素亮度 × 设备/源亮度)低于此值时,LED完全关闭(0 = 禁用)", + "targets.adaptive_fps": "自适应FPS:", + "targets.adaptive_fps.hint": "当设备无响应时自动降低发送速率,稳定后逐步恢复。推荐用于信号较弱的WiFi设备。", "search.open": "搜索 (Ctrl+K)", "search.placeholder": "搜索实体... (Ctrl+K)", @@ -943,5 +946,25 @@ "settings.restore.error": "恢复失败", "settings.restore.restarting": "服务器正在重启...", "settings.restore.restart_timeout": "服务器未响应。请手动刷新页面。", - "settings.button.close": "关闭" + "settings.button.close": "关闭", + + "settings.auto_backup.label": "自动备份", + "settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。", + "settings.auto_backup.enable": "启用自动备份", + "settings.auto_backup.interval_label": "间隔", + "settings.auto_backup.max_label": "最大备份数", + "settings.auto_backup.save": "保存设置", + "settings.auto_backup.saved": "自动备份设置已保存", + "settings.auto_backup.save_error": "保存自动备份设置失败", + "settings.auto_backup.last_backup": "上次备份", + "settings.auto_backup.never": "从未", + + "settings.saved_backups.label": "已保存的备份", + "settings.saved_backups.hint": "存储在服务器上的自动备份文件。下载到本地保存,或删除以释放空间。", + "settings.saved_backups.empty": "没有已保存的备份", + "settings.saved_backups.restore": "恢复", + "settings.saved_backups.download": "下载", + "settings.saved_backups.delete": "删除", + "settings.saved_backups.delete_confirm": "删除此备份文件?", + "settings.saved_backups.delete_error": "删除备份失败" } diff --git a/server/src/wled_controller/storage/picture_target_store.py b/server/src/wled_controller/storage/picture_target_store.py index 80038d0..a545278 100644 --- a/server/src/wled_controller/storage/picture_target_store.py +++ b/server/src/wled_controller/storage/picture_target_store.py @@ -100,6 +100,7 @@ class PictureTargetStore: keepalive_interval: float = 1.0, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, min_brightness_threshold: int = 0, + adaptive_fps: bool = False, key_colors_settings: Optional[KeyColorsSettings] = None, description: Optional[str] = None, picture_source_id: str = "", @@ -133,6 +134,7 @@ class PictureTargetStore: keepalive_interval=keepalive_interval, state_check_interval=state_check_interval, min_brightness_threshold=min_brightness_threshold, + adaptive_fps=adaptive_fps, description=description, auto_start=auto_start, created_at=now, @@ -170,6 +172,7 @@ class PictureTargetStore: keepalive_interval: Optional[float] = None, state_check_interval: Optional[int] = None, min_brightness_threshold: Optional[int] = None, + adaptive_fps: Optional[bool] = None, key_colors_settings: Optional[KeyColorsSettings] = None, description: Optional[str] = None, auto_start: Optional[bool] = None, @@ -199,6 +202,7 @@ class PictureTargetStore: keepalive_interval=keepalive_interval, state_check_interval=state_check_interval, min_brightness_threshold=min_brightness_threshold, + adaptive_fps=adaptive_fps, key_colors_settings=key_colors_settings, description=description, auto_start=auto_start, diff --git a/server/src/wled_controller/storage/wled_picture_target.py b/server/src/wled_controller/storage/wled_picture_target.py index f8deacd..16c9b4b 100644 --- a/server/src/wled_controller/storage/wled_picture_target.py +++ b/server/src/wled_controller/storage/wled_picture_target.py @@ -20,6 +20,7 @@ class WledPictureTarget(PictureTarget): keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL min_brightness_threshold: int = 0 # brightness below this → 0 (disabled when 0) + adaptive_fps: bool = False # auto-reduce FPS when device is unresponsive def register_with_manager(self, manager) -> None: """Register this WLED target with the processor manager.""" @@ -33,6 +34,7 @@ class WledPictureTarget(PictureTarget): state_check_interval=self.state_check_interval, brightness_value_source_id=self.brightness_value_source_id, min_brightness_threshold=self.min_brightness_threshold, + adaptive_fps=self.adaptive_fps, ) def sync_with_manager(self, manager, *, settings_changed: bool, @@ -46,6 +48,7 @@ class WledPictureTarget(PictureTarget): "keepalive_interval": self.keepalive_interval, "state_check_interval": self.state_check_interval, "min_brightness_threshold": self.min_brightness_threshold, + "adaptive_fps": self.adaptive_fps, }) if css_changed: manager.update_target_css(self.id, self.color_strip_source_id) @@ -57,7 +60,7 @@ class WledPictureTarget(PictureTarget): def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None, brightness_value_source_id=None, fps=None, keepalive_interval=None, state_check_interval=None, - min_brightness_threshold=None, + min_brightness_threshold=None, adaptive_fps=None, description=None, auto_start=None, **_kwargs) -> None: """Apply mutable field updates for WLED targets.""" super().update_fields(name=name, description=description, auto_start=auto_start) @@ -75,6 +78,8 @@ class WledPictureTarget(PictureTarget): self.state_check_interval = state_check_interval if min_brightness_threshold is not None: self.min_brightness_threshold = min_brightness_threshold + if adaptive_fps is not None: + self.adaptive_fps = adaptive_fps @property def has_picture_source(self) -> bool: @@ -90,6 +95,7 @@ class WledPictureTarget(PictureTarget): d["keepalive_interval"] = self.keepalive_interval d["state_check_interval"] = self.state_check_interval d["min_brightness_threshold"] = self.min_brightness_threshold + d["adaptive_fps"] = self.adaptive_fps return d @classmethod @@ -116,6 +122,7 @@ class WledPictureTarget(PictureTarget): keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)), state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL), min_brightness_threshold=data.get("min_brightness_threshold", 0), + adaptive_fps=data.get("adaptive_fps", False), description=data.get("description"), auto_start=data.get("auto_start", False), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), diff --git a/server/src/wled_controller/templates/modals/settings.html b/server/src/wled_controller/templates/modals/settings.html index df8d774..ea19752 100644 --- a/server/src/wled_controller/templates/modals/settings.html +++ b/server/src/wled_controller/templates/modals/settings.html @@ -27,6 +27,52 @@ + +
+
+ + +
+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
+ + +
+ +
+
+ diff --git a/server/src/wled_controller/templates/modals/target-editor.html b/server/src/wled_controller/templates/modals/target-editor.html index 795b190..a717ea0 100644 --- a/server/src/wled_controller/templates/modals/target-editor.html +++ b/server/src/wled_controller/templates/modals/target-editor.html @@ -84,6 +84,18 @@ +
+
+ + +
+ + +
+