Add adaptive FPS and honest device reachability during streaming
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.value_source_store import ValueSourceStore
|
||||||
from wled_controller.storage.profile_store import ProfileStore
|
from wled_controller.storage.profile_store import ProfileStore
|
||||||
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
||||||
|
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||||
|
|
||||||
# Global instances (initialized in main.py)
|
# Global instances (initialized in main.py)
|
||||||
|
_auto_backup_engine: AutoBackupEngine | None = None
|
||||||
_device_store: DeviceStore | None = None
|
_device_store: DeviceStore | None = None
|
||||||
_template_store: TemplateStore | None = None
|
_template_store: TemplateStore | None = None
|
||||||
_pp_template_store: PostprocessingTemplateStore | None = None
|
_pp_template_store: PostprocessingTemplateStore | None = None
|
||||||
@@ -121,6 +123,13 @@ def get_profile_engine() -> ProfileEngine:
|
|||||||
return _profile_engine
|
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(
|
def init_dependencies(
|
||||||
device_store: DeviceStore,
|
device_store: DeviceStore,
|
||||||
template_store: TemplateStore,
|
template_store: TemplateStore,
|
||||||
@@ -135,12 +144,13 @@ def init_dependencies(
|
|||||||
value_source_store: ValueSourceStore | None = None,
|
value_source_store: ValueSourceStore | None = None,
|
||||||
profile_store: ProfileStore | None = None,
|
profile_store: ProfileStore | None = None,
|
||||||
profile_engine: ProfileEngine | None = None,
|
profile_engine: ProfileEngine | None = None,
|
||||||
|
auto_backup_engine: AutoBackupEngine | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize global dependencies."""
|
"""Initialize global dependencies."""
|
||||||
global _device_store, _template_store, _processor_manager
|
global _device_store, _template_store, _processor_manager
|
||||||
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
|
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
|
||||||
global _color_strip_store, _audio_source_store, _audio_template_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
|
_device_store = device_store
|
||||||
_template_store = template_store
|
_template_store = template_store
|
||||||
_processor_manager = processor_manager
|
_processor_manager = processor_manager
|
||||||
@@ -154,3 +164,4 @@ def init_dependencies(
|
|||||||
_value_source_store = value_source_store
|
_value_source_store = value_source_store
|
||||||
_profile_store = profile_store
|
_profile_store = profile_store
|
||||||
_profile_engine = profile_engine
|
_profile_engine = profile_engine
|
||||||
|
_auto_backup_engine = auto_backup_engine
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ def _target_to_response(target) -> PictureTargetResponse:
|
|||||||
keepalive_interval=target.keepalive_interval,
|
keepalive_interval=target.keepalive_interval,
|
||||||
state_check_interval=target.state_check_interval,
|
state_check_interval=target.state_check_interval,
|
||||||
min_brightness_threshold=target.min_brightness_threshold,
|
min_brightness_threshold=target.min_brightness_threshold,
|
||||||
|
adaptive_fps=target.adaptive_fps,
|
||||||
description=target.description,
|
description=target.description,
|
||||||
auto_start=target.auto_start,
|
auto_start=target.auto_start,
|
||||||
created_at=target.created_at,
|
created_at=target.created_at,
|
||||||
@@ -161,6 +162,7 @@ async def create_target(
|
|||||||
keepalive_interval=data.keepalive_interval,
|
keepalive_interval=data.keepalive_interval,
|
||||||
state_check_interval=data.state_check_interval,
|
state_check_interval=data.state_check_interval,
|
||||||
min_brightness_threshold=data.min_brightness_threshold,
|
min_brightness_threshold=data.min_brightness_threshold,
|
||||||
|
adaptive_fps=data.adaptive_fps,
|
||||||
picture_source_id=data.picture_source_id,
|
picture_source_id=data.picture_source_id,
|
||||||
key_colors_settings=kc_settings,
|
key_colors_settings=kc_settings,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
@@ -279,6 +281,7 @@ async def update_target(
|
|||||||
keepalive_interval=data.keepalive_interval,
|
keepalive_interval=data.keepalive_interval,
|
||||||
state_check_interval=data.state_check_interval,
|
state_check_interval=data.state_check_interval,
|
||||||
min_brightness_threshold=data.min_brightness_threshold,
|
min_brightness_threshold=data.min_brightness_threshold,
|
||||||
|
adaptive_fps=data.adaptive_fps,
|
||||||
key_colors_settings=kc_settings,
|
key_colors_settings=kc_settings,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
auto_start=data.auto_start,
|
auto_start=data.auto_start,
|
||||||
@@ -299,6 +302,7 @@ async def update_target(
|
|||||||
data.keepalive_interval is not None or
|
data.keepalive_interval is not None or
|
||||||
data.state_check_interval is not None or
|
data.state_check_interval is not None or
|
||||||
data.min_brightness_threshold 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),
|
data.key_colors_settings is not None),
|
||||||
css_changed=data.color_strip_source_id is not None,
|
css_changed=data.color_strip_source_id is not None,
|
||||||
device_changed=data.device_id is not None,
|
device_changed=data.device_id is not None,
|
||||||
|
|||||||
@@ -16,8 +16,12 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from wled_controller import __version__
|
from wled_controller import __version__
|
||||||
from wled_controller.api.auth import AuthRequired
|
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 (
|
from wled_controller.api.schemas.system import (
|
||||||
|
AutoBackupSettings,
|
||||||
|
AutoBackupStatusResponse,
|
||||||
|
BackupFileInfo,
|
||||||
|
BackupListResponse,
|
||||||
DisplayInfo,
|
DisplayInfo,
|
||||||
DisplayListResponse,
|
DisplayListResponse,
|
||||||
GpuInfo,
|
GpuInfo,
|
||||||
@@ -27,6 +31,7 @@ from wled_controller.api.schemas.system import (
|
|||||||
RestoreResponse,
|
RestoreResponse,
|
||||||
VersionResponse,
|
VersionResponse,
|
||||||
)
|
)
|
||||||
|
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||||
from wled_controller.config import get_config
|
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 atomic_write_json, get_logger
|
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)
|
# ADB helpers (for Android / scrcpy engine)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
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)
|
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")
|
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
|
# KC target fields
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)")
|
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)")
|
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)
|
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)
|
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")
|
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
|
# KC target fields
|
||||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)")
|
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)")
|
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)")
|
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)")
|
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)")
|
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
|
# KC target fields
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
|
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
|
||||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")
|
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_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_last_checked: Optional[datetime] = Field(None, description="Last health check time")
|
||||||
device_error: Optional[str] = Field(None, description="Last health check error")
|
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):
|
class TargetMetricsResponse(BaseModel):
|
||||||
|
|||||||
@@ -79,3 +79,38 @@ class RestoreResponse(BaseModel):
|
|||||||
missing_stores: List[str] = Field(default_factory=list, description="Store keys not found in backup")
|
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")
|
restart_scheduled: bool = Field(description="Whether server restart was scheduled")
|
||||||
message: str = Field(description="Human-readable status message")
|
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
|
||||||
|
|||||||
0
server/src/wled_controller/core/backup/__init__.py
Normal file
0
server/src/wled_controller/core/backup/__init__.py
Normal file
220
server/src/wled_controller/core/backup/auto_backup.py
Normal file
220
server/src/wled_controller/core/backup/auto_backup.py
Normal file
@@ -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
|
||||||
@@ -322,6 +322,7 @@ class ProcessorManager:
|
|||||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||||
brightness_value_source_id: str = "",
|
brightness_value_source_id: str = "",
|
||||||
min_brightness_threshold: int = 0,
|
min_brightness_threshold: int = 0,
|
||||||
|
adaptive_fps: bool = False,
|
||||||
):
|
):
|
||||||
"""Register a WLED target processor."""
|
"""Register a WLED target processor."""
|
||||||
if target_id in self._processors:
|
if target_id in self._processors:
|
||||||
@@ -338,6 +339,7 @@ class ProcessorManager:
|
|||||||
state_check_interval=state_check_interval,
|
state_check_interval=state_check_interval,
|
||||||
brightness_value_source_id=brightness_value_source_id,
|
brightness_value_source_id=brightness_value_source_id,
|
||||||
min_brightness_threshold=min_brightness_threshold,
|
min_brightness_threshold=min_brightness_threshold,
|
||||||
|
adaptive_fps=adaptive_fps,
|
||||||
ctx=self._build_context(),
|
ctx=self._build_context(),
|
||||||
)
|
)
|
||||||
self._processors[target_id] = proc
|
self._processors[target_id] = proc
|
||||||
@@ -834,11 +836,7 @@ class ProcessorManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
while self._health_monitoring_active:
|
while self._health_monitoring_active:
|
||||||
if not self._device_is_processing(device_id):
|
await self._check_device_health(device_id)
|
||||||
await self._check_device_health(device_id)
|
|
||||||
else:
|
|
||||||
if state.health:
|
|
||||||
state.health.online = True
|
|
||||||
await asyncio.sleep(check_interval)
|
await asyncio.sleep(check_interval)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ class ProcessingMetrics:
|
|||||||
# KC targets
|
# KC targets
|
||||||
timing_calc_colors_ms: float = 0.0
|
timing_calc_colors_ms: float = 0.0
|
||||||
timing_broadcast_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
|
@dataclass
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from wled_controller.core.devices.led_client import LEDClient, create_led_client, get_device_capabilities
|
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,
|
state_check_interval: int = 30,
|
||||||
brightness_value_source_id: str = "",
|
brightness_value_source_id: str = "",
|
||||||
min_brightness_threshold: int = 0,
|
min_brightness_threshold: int = 0,
|
||||||
|
adaptive_fps: bool = False,
|
||||||
ctx: TargetContext = None,
|
ctx: TargetContext = None,
|
||||||
):
|
):
|
||||||
super().__init__(target_id, ctx)
|
super().__init__(target_id, ctx)
|
||||||
@@ -47,6 +49,11 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._css_id = color_strip_source_id
|
self._css_id = color_strip_source_id
|
||||||
self._brightness_vs_id = brightness_value_source_id
|
self._brightness_vs_id = brightness_value_source_id
|
||||||
self._min_brightness_threshold = min_brightness_threshold
|
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)
|
# Runtime state (populated on start)
|
||||||
self._led_client: Optional[LEDClient] = None
|
self._led_client: Optional[LEDClient] = None
|
||||||
@@ -60,6 +67,8 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
# LED preview WebSocket clients
|
# LED preview WebSocket clients
|
||||||
self._preview_clients: list = []
|
self._preview_clients: list = []
|
||||||
|
self._last_preview_colors: np.ndarray | None = None
|
||||||
|
self._last_preview_brightness: int = 255
|
||||||
|
|
||||||
# ----- Properties -----
|
# ----- Properties -----
|
||||||
|
|
||||||
@@ -205,6 +214,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
if isinstance(settings, dict):
|
if isinstance(settings, dict):
|
||||||
if "fps" in settings:
|
if "fps" in settings:
|
||||||
self._target_fps = settings["fps"] if settings["fps"] > 0 else 30
|
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
|
css_manager = self._ctx.color_strip_stream_manager
|
||||||
if css_manager and self._is_running and self._css_id:
|
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)
|
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"]
|
self._state_check_interval = settings["state_check_interval"]
|
||||||
if "min_brightness_threshold" in settings:
|
if "min_brightness_threshold" in settings:
|
||||||
self._min_brightness_threshold = settings["min_brightness_threshold"]
|
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}")
|
logger.info(f"Updated settings for target {self._target_id}")
|
||||||
|
|
||||||
def update_device(self, device_id: str) -> None:
|
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}")
|
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]:
|
def get_display_index(self) -> Optional[int]:
|
||||||
"""Display index being captured, from the active stream."""
|
"""Display index being captured, from the active stream."""
|
||||||
if self._resolved_display_index is not None:
|
if self._resolved_display_index is not None:
|
||||||
@@ -349,6 +371,8 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
"needs_keepalive": self._needs_keepalive,
|
"needs_keepalive": self._needs_keepalive,
|
||||||
"last_update": metrics.last_update,
|
"last_update": metrics.last_update,
|
||||||
"errors": [metrics.last_error] if metrics.last_error else [],
|
"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:
|
def get_metrics(self) -> dict:
|
||||||
@@ -432,6 +456,17 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
def add_led_preview_client(self, ws) -> None:
|
def add_led_preview_client(self, ws) -> None:
|
||||||
self._preview_clients.append(ws)
|
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:
|
def remove_led_preview_client(self, ws) -> None:
|
||||||
if ws in self._preview_clients:
|
if ws in self._preview_clients:
|
||||||
@@ -536,9 +571,22 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
_diag_device_info: Optional[DeviceInfo] = None
|
_diag_device_info: Optional[DeviceInfo] = None
|
||||||
_diag_device_info_age = 0
|
_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(
|
logger.info(
|
||||||
f"Processing loop started for target {self._target_id} "
|
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()
|
next_frame_time = time.perf_counter()
|
||||||
@@ -548,7 +596,61 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
while self._is_running:
|
while self._is_running:
|
||||||
loop_start = now = time.perf_counter()
|
loop_start = now = time.perf_counter()
|
||||||
target_fps = self._target_fps if self._target_fps > 0 else 30
|
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
|
keepalive_interval = self._keepalive_interval
|
||||||
|
|
||||||
# Detect hot-swapped CSS stream
|
# Detect hot-swapped CSS stream
|
||||||
@@ -640,6 +742,8 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
# Fit to device LED count and apply brightness
|
# Fit to device LED count and apply brightness
|
||||||
device_colors = self._fit_to_device(frame, _total_leds)
|
device_colors = self._fit_to_device(frame, _total_leds)
|
||||||
send_colors = _cached_brightness(device_colors, cur_brightness)
|
send_colors = _cached_brightness(device_colors, cur_brightness)
|
||||||
|
self._last_preview_colors = send_colors
|
||||||
|
self._last_preview_brightness = cur_brightness
|
||||||
|
|
||||||
# Send to LED device
|
# Send to LED device
|
||||||
if not self._is_running or self._led_client is None:
|
if not self._is_running or self._led_client is None:
|
||||||
@@ -752,4 +856,11 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._is_running = False
|
self._is_running = False
|
||||||
raise
|
raise
|
||||||
finally:
|
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}")
|
logger.info(f"Processing loop ended for target {self._target_id}")
|
||||||
|
|||||||
@@ -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.value_source_store import ValueSourceStore
|
||||||
from wled_controller.storage.profile_store import ProfileStore
|
from wled_controller.storage.profile_store import ProfileStore
|
||||||
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
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
|
from wled_controller.utils import setup_logging, get_logger
|
||||||
|
|
||||||
# Initialize logging
|
# Initialize logging
|
||||||
@@ -101,6 +103,14 @@ async def lifespan(app: FastAPI):
|
|||||||
# Create profile engine (needs processor_manager)
|
# Create profile engine (needs processor_manager)
|
||||||
profile_engine = ProfileEngine(profile_store, 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
|
# Initialize API dependencies
|
||||||
init_dependencies(
|
init_dependencies(
|
||||||
device_store, template_store, processor_manager,
|
device_store, template_store, processor_manager,
|
||||||
@@ -114,6 +124,7 @@ async def lifespan(app: FastAPI):
|
|||||||
value_source_store=value_source_store,
|
value_source_store=value_source_store,
|
||||||
profile_store=profile_store,
|
profile_store=profile_store,
|
||||||
profile_engine=profile_engine,
|
profile_engine=profile_engine,
|
||||||
|
auto_backup_engine=auto_backup_engine,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register devices in processor manager for health monitoring
|
# 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)
|
# Start profile engine (evaluates conditions and auto-starts/stops targets)
|
||||||
await profile_engine.start()
|
await profile_engine.start()
|
||||||
|
|
||||||
|
# Start auto-backup engine (periodic configuration backups)
|
||||||
|
await auto_backup_engine.start()
|
||||||
|
|
||||||
# Auto-start targets with auto_start=True
|
# Auto-start targets with auto_start=True
|
||||||
auto_started = 0
|
auto_started = 0
|
||||||
for target in targets:
|
for target in targets:
|
||||||
@@ -172,6 +186,12 @@ async def lifespan(app: FastAPI):
|
|||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("Shutting down LED Grab")
|
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)
|
# Stop profile engine first (deactivates profile-managed targets)
|
||||||
try:
|
try:
|
||||||
await profile_engine.stop()
|
await profile_engine.stop()
|
||||||
|
|||||||
@@ -549,6 +549,10 @@ ul.section-tip li {
|
|||||||
color: #4CAF50;
|
color: #4CAF50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fps-unreachable {
|
||||||
|
color: #ff5252;
|
||||||
|
}
|
||||||
|
|
||||||
/* Timing breakdown bar */
|
/* Timing breakdown bar */
|
||||||
.timing-breakdown {
|
.timing-breakdown {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ 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 {
|
import {
|
||||||
openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected,
|
openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected,
|
||||||
|
saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
|
||||||
} from './features/settings.js';
|
} from './features/settings.js';
|
||||||
|
|
||||||
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||||
@@ -388,11 +389,15 @@ Object.assign(window, {
|
|||||||
openCommandPalette,
|
openCommandPalette,
|
||||||
closeCommandPalette,
|
closeCommandPalette,
|
||||||
|
|
||||||
// settings (backup / restore)
|
// settings (backup / restore / auto-backup)
|
||||||
openSettingsModal,
|
openSettingsModal,
|
||||||
closeSettingsModal,
|
closeSettingsModal,
|
||||||
downloadBackup,
|
downloadBackup,
|
||||||
handleRestoreFileSelected,
|
handleRestoreFileSelected,
|
||||||
|
saveAutoBackupSettings,
|
||||||
|
restoreSavedBackup,
|
||||||
|
downloadSavedBackup,
|
||||||
|
deleteSavedBackup,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Global keyboard shortcuts ───
|
// ─── Global keyboard shortcuts ───
|
||||||
|
|||||||
@@ -207,20 +207,32 @@ function _updateRunningMetrics(enrichedRunning) {
|
|||||||
// Update text values (use cached refs, fallback to querySelector)
|
// Update text values (use cached refs, fallback to querySelector)
|
||||||
const cached = _metricsElements.get(target.id);
|
const cached = _metricsElements.get(target.id);
|
||||||
const fpsEl = cached?.fps || document.querySelector(`[data-fps-text="${target.id}"]`);
|
const fpsEl = cached?.fps || document.querySelector(`[data-fps-text="${target.id}"]`);
|
||||||
if (fpsEl) fpsEl.innerHTML = `${fpsCurrent}<span class="dashboard-fps-target">/${fpsTarget}</span>`
|
if (fpsEl) {
|
||||||
+ `<span class="dashboard-fps-avg">avg ${fpsActual}</span>`;
|
const effFps = state.fps_effective;
|
||||||
|
const fpsTargetLabel = (effFps != null && effFps < fpsTarget)
|
||||||
|
? `${fpsCurrent}<span class="dashboard-fps-target">/${effFps}↓${fpsTarget}</span>`
|
||||||
|
: `${fpsCurrent}<span class="dashboard-fps-target">/${fpsTarget}</span>`;
|
||||||
|
const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : '';
|
||||||
|
fpsEl.innerHTML = `<span class="${unreachableClass}">${fpsTargetLabel}</span>`
|
||||||
|
+ `<span class="dashboard-fps-avg">avg ${fpsActual}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`);
|
const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`);
|
||||||
if (errorsEl) errorsEl.textContent = `${errors > 0 ? ICON_WARNING : ICON_OK} ${errors}`;
|
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';
|
const isLed = target.target_type === 'led' || target.target_type === 'wled';
|
||||||
if (isLed) {
|
if (isLed) {
|
||||||
const row = cached?.row || document.querySelector(`[data-target-id="${target.id}"]`);
|
const row = cached?.row || document.querySelector(`[data-target-id="${target.id}"]`);
|
||||||
if (row) {
|
if (row) {
|
||||||
const dot = row.querySelector('.health-dot');
|
const dot = row.querySelector('.health-dot');
|
||||||
if (dot && state.device_last_checked != null) {
|
if (dot) {
|
||||||
dot.className = `health-dot ${state.device_online ? 'health-online' : 'health-offline'}`;
|
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'}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const settingsModal = new Modal('settings-modal');
|
|||||||
export function openSettingsModal() {
|
export function openSettingsModal() {
|
||||||
document.getElementById('settings-error').style.display = 'none';
|
document.getElementById('settings-error').style.display = 'none';
|
||||||
settingsModal.open();
|
settingsModal.open();
|
||||||
|
loadAutoBackupSettings();
|
||||||
|
loadBackupList();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeSettingsModal() {
|
export function closeSettingsModal() {
|
||||||
@@ -135,3 +137,164 @@ function pollHealth() {
|
|||||||
// Wait a moment before first check to let the server shut down
|
// Wait a moment before first check to let the server shut down
|
||||||
setTimeout(check, 2000);
|
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 = `<div style="color:var(--text-muted);font-size:0.85rem;">${t('settings.saved_backups.empty')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = data.backups.map(b => {
|
||||||
|
const sizeKB = (b.size_bytes / 1024).toFixed(1);
|
||||||
|
const date = new Date(b.created_at).toLocaleString();
|
||||||
|
return `<div style="display:flex;align-items:center;gap:0.5rem;padding:0.3rem 0;border-bottom:1px solid var(--border-color);font-size:0.82rem;">
|
||||||
|
<div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${b.filename}">
|
||||||
|
<span>${date}</span>
|
||||||
|
<span style="color:var(--text-muted);margin-left:0.3rem;">${sizeKB} KB</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="restoreSavedBackup('${b.filename}')" title="${t('settings.saved_backups.restore')}" style="padding:2px 6px;font-size:0.8rem;">↺</button>
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="downloadSavedBackup('${b.filename}')" title="${t('settings.saved_backups.download')}" style="padding:2px 6px;font-size:0.8rem;">⬇</button>
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="deleteSavedBackup('${b.filename}')" title="${t('settings.saved_backups.delete')}" style="padding:2px 6px;font-size:0.8rem;">✕</button>
|
||||||
|
</div>`;
|
||||||
|
}).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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ class TargetEditorModal extends Modal {
|
|||||||
brightness_threshold: document.getElementById('target-editor-brightness-threshold').value,
|
brightness_threshold: document.getElementById('target-editor-brightness-threshold').value,
|
||||||
fps: document.getElementById('target-editor-fps').value,
|
fps: document.getElementById('target-editor-fps').value,
|
||||||
keepalive_interval: document.getElementById('target-editor-keepalive-interval').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 = thresh;
|
||||||
document.getElementById('target-editor-brightness-threshold-value').textContent = 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 || '');
|
_populateCssDropdown(target.color_strip_source_id || '');
|
||||||
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
|
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
|
||||||
} else if (cloneData) {
|
} 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 = cloneThresh;
|
||||||
document.getElementById('target-editor-brightness-threshold-value').textContent = 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 || '');
|
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
||||||
_populateBrightnessVsDropdown(cloneData.brightness_value_source_id || '');
|
_populateBrightnessVsDropdown(cloneData.brightness_value_source_id || '');
|
||||||
} else {
|
} 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 = 0;
|
||||||
document.getElementById('target-editor-brightness-threshold-value').textContent = '0';
|
document.getElementById('target-editor-brightness-threshold-value').textContent = '0';
|
||||||
|
|
||||||
|
document.getElementById('target-editor-adaptive-fps').checked = false;
|
||||||
|
|
||||||
_populateCssDropdown('');
|
_populateCssDropdown('');
|
||||||
_populateBrightnessVsDropdown('');
|
_populateBrightnessVsDropdown('');
|
||||||
}
|
}
|
||||||
@@ -364,6 +371,8 @@ export async function saveTargetEditor() {
|
|||||||
const brightnessVsId = document.getElementById('target-editor-brightness-vs').value;
|
const brightnessVsId = document.getElementById('target-editor-brightness-vs').value;
|
||||||
const minBrightnessThreshold = parseInt(document.getElementById('target-editor-brightness-threshold').value) || 0;
|
const minBrightnessThreshold = parseInt(document.getElementById('target-editor-brightness-threshold').value) || 0;
|
||||||
|
|
||||||
|
const adaptiveFps = document.getElementById('target-editor-adaptive-fps').checked;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name,
|
name,
|
||||||
device_id: deviceId,
|
device_id: deviceId,
|
||||||
@@ -372,6 +381,7 @@ export async function saveTargetEditor() {
|
|||||||
min_brightness_threshold: minBrightnessThreshold,
|
min_brightness_threshold: minBrightnessThreshold,
|
||||||
fps,
|
fps,
|
||||||
keepalive_interval: standbyInterval,
|
keepalive_interval: standbyInterval,
|
||||||
|
adaptive_fps: adaptiveFps,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -765,8 +775,29 @@ function _patchTargetMetrics(target) {
|
|||||||
const metrics = target.metrics || {};
|
const metrics = target.metrics || {};
|
||||||
|
|
||||||
const fps = card.querySelector('[data-tm="fps"]');
|
const fps = card.querySelector('[data-tm="fps"]');
|
||||||
if (fps) fps.innerHTML = `${state.fps_current ?? 0}<span class="target-fps-target">/${state.fps_target || 0}</span>`
|
if (fps) {
|
||||||
+ `<span class="target-fps-avg">avg ${state.fps_actual?.toFixed(1) || '0.0'}</span>`;
|
const effFps = state.fps_effective;
|
||||||
|
const tgtFps = state.fps_target || 0;
|
||||||
|
const fpsLabel = (effFps != null && effFps < tgtFps)
|
||||||
|
? `${state.fps_current ?? 0}<span class="target-fps-target">/${effFps}↓${tgtFps}</span>`
|
||||||
|
: `${state.fps_current ?? 0}<span class="target-fps-target">/${tgtFps}</span>`;
|
||||||
|
const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : '';
|
||||||
|
fps.innerHTML = `<span class="${unreachableClass}">${fpsLabel}</span>`
|
||||||
|
+ `<span class="target-fps-avg">avg ${state.fps_actual?.toFixed(1) || '0.0'}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"]');
|
const timing = card.querySelector('[data-tm="timing"]');
|
||||||
if (timing && state.timing_total_ms != null) timing.innerHTML = _buildLedTimingHTML(state);
|
if (timing && state.timing_total_ms != null) timing.innerHTML = _buildLedTimingHTML(state);
|
||||||
|
|||||||
@@ -169,6 +169,7 @@
|
|||||||
"device.metrics.device_fps": "Device refresh rate",
|
"device.metrics.device_fps": "Device refresh rate",
|
||||||
"device.health.online": "Online",
|
"device.health.online": "Online",
|
||||||
"device.health.offline": "Offline",
|
"device.health.offline": "Offline",
|
||||||
|
"device.health.streaming_unreachable": "Unreachable during streaming",
|
||||||
"device.health.checking": "Checking...",
|
"device.health.checking": "Checking...",
|
||||||
"device.tutorial.start": "Start tutorial",
|
"device.tutorial.start": "Start tutorial",
|
||||||
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device",
|
"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.brightness_vs.none": "None (device brightness)",
|
||||||
"targets.min_brightness_threshold": "Min Brightness Threshold:",
|
"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.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.open": "Search (Ctrl+K)",
|
||||||
"search.placeholder": "Search entities... (Ctrl+K)",
|
"search.placeholder": "Search entities... (Ctrl+K)",
|
||||||
@@ -943,5 +946,25 @@
|
|||||||
"settings.restore.error": "Restore failed",
|
"settings.restore.error": "Restore failed",
|
||||||
"settings.restore.restarting": "Server is restarting...",
|
"settings.restore.restarting": "Server is restarting...",
|
||||||
"settings.restore.restart_timeout": "Server did not respond. Please refresh the page manually.",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,7 @@
|
|||||||
"device.metrics.device_fps": "Частота обновления устройства",
|
"device.metrics.device_fps": "Частота обновления устройства",
|
||||||
"device.health.online": "Онлайн",
|
"device.health.online": "Онлайн",
|
||||||
"device.health.offline": "Недоступен",
|
"device.health.offline": "Недоступен",
|
||||||
|
"device.health.streaming_unreachable": "Недоступен во время стриминга",
|
||||||
"device.health.checking": "Проверка...",
|
"device.health.checking": "Проверка...",
|
||||||
"device.tutorial.start": "Начать обучение",
|
"device.tutorial.start": "Начать обучение",
|
||||||
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
|
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
|
||||||
@@ -912,6 +913,8 @@
|
|||||||
"targets.brightness_vs.none": "Нет (яркость устройства)",
|
"targets.brightness_vs.none": "Нет (яркость устройства)",
|
||||||
"targets.min_brightness_threshold": "Мин. порог яркости:",
|
"targets.min_brightness_threshold": "Мин. порог яркости:",
|
||||||
"targets.min_brightness_threshold.hint": "Если итоговая яркость (яркость пикселей × яркость устройства/источника) ниже этого значения, светодиоды полностью выключаются (0 = отключено)",
|
"targets.min_brightness_threshold.hint": "Если итоговая яркость (яркость пикселей × яркость устройства/источника) ниже этого значения, светодиоды полностью выключаются (0 = отключено)",
|
||||||
|
"targets.adaptive_fps": "Адаптивный FPS:",
|
||||||
|
"targets.adaptive_fps.hint": "Автоматически снижает частоту отправки, когда устройство перестаёт отвечать, и постепенно восстанавливает её при стабилизации. Рекомендуется для WiFi-устройств со слабым сигналом.",
|
||||||
|
|
||||||
"search.open": "Поиск (Ctrl+K)",
|
"search.open": "Поиск (Ctrl+K)",
|
||||||
"search.placeholder": "Поиск... (Ctrl+K)",
|
"search.placeholder": "Поиск... (Ctrl+K)",
|
||||||
@@ -943,5 +946,25 @@
|
|||||||
"settings.restore.error": "Ошибка восстановления",
|
"settings.restore.error": "Ошибка восстановления",
|
||||||
"settings.restore.restarting": "Сервер перезапускается...",
|
"settings.restore.restarting": "Сервер перезапускается...",
|
||||||
"settings.restore.restart_timeout": "Сервер не отвечает. Обновите страницу вручную.",
|
"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": "Не удалось удалить копию"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,7 @@
|
|||||||
"device.metrics.device_fps": "设备刷新率",
|
"device.metrics.device_fps": "设备刷新率",
|
||||||
"device.health.online": "在线",
|
"device.health.online": "在线",
|
||||||
"device.health.offline": "离线",
|
"device.health.offline": "离线",
|
||||||
|
"device.health.streaming_unreachable": "流传输期间不可达",
|
||||||
"device.health.checking": "检测中...",
|
"device.health.checking": "检测中...",
|
||||||
"device.tutorial.start": "开始教程",
|
"device.tutorial.start": "开始教程",
|
||||||
"device.tip.metadata": "设备信息(LED 数量、类型、颜色通道)从设备自动检测",
|
"device.tip.metadata": "设备信息(LED 数量、类型、颜色通道)从设备自动检测",
|
||||||
@@ -912,6 +913,8 @@
|
|||||||
"targets.brightness_vs.none": "无(设备亮度)",
|
"targets.brightness_vs.none": "无(设备亮度)",
|
||||||
"targets.min_brightness_threshold": "最低亮度阈值:",
|
"targets.min_brightness_threshold": "最低亮度阈值:",
|
||||||
"targets.min_brightness_threshold.hint": "当有效输出亮度(像素亮度 × 设备/源亮度)低于此值时,LED完全关闭(0 = 禁用)",
|
"targets.min_brightness_threshold.hint": "当有效输出亮度(像素亮度 × 设备/源亮度)低于此值时,LED完全关闭(0 = 禁用)",
|
||||||
|
"targets.adaptive_fps": "自适应FPS:",
|
||||||
|
"targets.adaptive_fps.hint": "当设备无响应时自动降低发送速率,稳定后逐步恢复。推荐用于信号较弱的WiFi设备。",
|
||||||
|
|
||||||
"search.open": "搜索 (Ctrl+K)",
|
"search.open": "搜索 (Ctrl+K)",
|
||||||
"search.placeholder": "搜索实体... (Ctrl+K)",
|
"search.placeholder": "搜索实体... (Ctrl+K)",
|
||||||
@@ -943,5 +946,25 @@
|
|||||||
"settings.restore.error": "恢复失败",
|
"settings.restore.error": "恢复失败",
|
||||||
"settings.restore.restarting": "服务器正在重启...",
|
"settings.restore.restarting": "服务器正在重启...",
|
||||||
"settings.restore.restart_timeout": "服务器未响应。请手动刷新页面。",
|
"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": "删除备份失败"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ class PictureTargetStore:
|
|||||||
keepalive_interval: float = 1.0,
|
keepalive_interval: float = 1.0,
|
||||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||||
min_brightness_threshold: int = 0,
|
min_brightness_threshold: int = 0,
|
||||||
|
adaptive_fps: bool = False,
|
||||||
key_colors_settings: Optional[KeyColorsSettings] = None,
|
key_colors_settings: Optional[KeyColorsSettings] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
picture_source_id: str = "",
|
picture_source_id: str = "",
|
||||||
@@ -133,6 +134,7 @@ class PictureTargetStore:
|
|||||||
keepalive_interval=keepalive_interval,
|
keepalive_interval=keepalive_interval,
|
||||||
state_check_interval=state_check_interval,
|
state_check_interval=state_check_interval,
|
||||||
min_brightness_threshold=min_brightness_threshold,
|
min_brightness_threshold=min_brightness_threshold,
|
||||||
|
adaptive_fps=adaptive_fps,
|
||||||
description=description,
|
description=description,
|
||||||
auto_start=auto_start,
|
auto_start=auto_start,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
@@ -170,6 +172,7 @@ class PictureTargetStore:
|
|||||||
keepalive_interval: Optional[float] = None,
|
keepalive_interval: Optional[float] = None,
|
||||||
state_check_interval: Optional[int] = None,
|
state_check_interval: Optional[int] = None,
|
||||||
min_brightness_threshold: Optional[int] = None,
|
min_brightness_threshold: Optional[int] = None,
|
||||||
|
adaptive_fps: Optional[bool] = None,
|
||||||
key_colors_settings: Optional[KeyColorsSettings] = None,
|
key_colors_settings: Optional[KeyColorsSettings] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
auto_start: Optional[bool] = None,
|
auto_start: Optional[bool] = None,
|
||||||
@@ -199,6 +202,7 @@ class PictureTargetStore:
|
|||||||
keepalive_interval=keepalive_interval,
|
keepalive_interval=keepalive_interval,
|
||||||
state_check_interval=state_check_interval,
|
state_check_interval=state_check_interval,
|
||||||
min_brightness_threshold=min_brightness_threshold,
|
min_brightness_threshold=min_brightness_threshold,
|
||||||
|
adaptive_fps=adaptive_fps,
|
||||||
key_colors_settings=key_colors_settings,
|
key_colors_settings=key_colors_settings,
|
||||||
description=description,
|
description=description,
|
||||||
auto_start=auto_start,
|
auto_start=auto_start,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class WledPictureTarget(PictureTarget):
|
|||||||
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
||||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
||||||
min_brightness_threshold: int = 0 # brightness below this → 0 (disabled when 0)
|
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:
|
def register_with_manager(self, manager) -> None:
|
||||||
"""Register this WLED target with the processor manager."""
|
"""Register this WLED target with the processor manager."""
|
||||||
@@ -33,6 +34,7 @@ class WledPictureTarget(PictureTarget):
|
|||||||
state_check_interval=self.state_check_interval,
|
state_check_interval=self.state_check_interval,
|
||||||
brightness_value_source_id=self.brightness_value_source_id,
|
brightness_value_source_id=self.brightness_value_source_id,
|
||||||
min_brightness_threshold=self.min_brightness_threshold,
|
min_brightness_threshold=self.min_brightness_threshold,
|
||||||
|
adaptive_fps=self.adaptive_fps,
|
||||||
)
|
)
|
||||||
|
|
||||||
def sync_with_manager(self, manager, *, settings_changed: bool,
|
def sync_with_manager(self, manager, *, settings_changed: bool,
|
||||||
@@ -46,6 +48,7 @@ class WledPictureTarget(PictureTarget):
|
|||||||
"keepalive_interval": self.keepalive_interval,
|
"keepalive_interval": self.keepalive_interval,
|
||||||
"state_check_interval": self.state_check_interval,
|
"state_check_interval": self.state_check_interval,
|
||||||
"min_brightness_threshold": self.min_brightness_threshold,
|
"min_brightness_threshold": self.min_brightness_threshold,
|
||||||
|
"adaptive_fps": self.adaptive_fps,
|
||||||
})
|
})
|
||||||
if css_changed:
|
if css_changed:
|
||||||
manager.update_target_css(self.id, self.color_strip_source_id)
|
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,
|
def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None,
|
||||||
brightness_value_source_id=None,
|
brightness_value_source_id=None,
|
||||||
fps=None, keepalive_interval=None, state_check_interval=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:
|
description=None, auto_start=None, **_kwargs) -> None:
|
||||||
"""Apply mutable field updates for WLED targets."""
|
"""Apply mutable field updates for WLED targets."""
|
||||||
super().update_fields(name=name, description=description, auto_start=auto_start)
|
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
|
self.state_check_interval = state_check_interval
|
||||||
if min_brightness_threshold is not None:
|
if min_brightness_threshold is not None:
|
||||||
self.min_brightness_threshold = min_brightness_threshold
|
self.min_brightness_threshold = min_brightness_threshold
|
||||||
|
if adaptive_fps is not None:
|
||||||
|
self.adaptive_fps = adaptive_fps
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_picture_source(self) -> bool:
|
def has_picture_source(self) -> bool:
|
||||||
@@ -90,6 +95,7 @@ class WledPictureTarget(PictureTarget):
|
|||||||
d["keepalive_interval"] = self.keepalive_interval
|
d["keepalive_interval"] = self.keepalive_interval
|
||||||
d["state_check_interval"] = self.state_check_interval
|
d["state_check_interval"] = self.state_check_interval
|
||||||
d["min_brightness_threshold"] = self.min_brightness_threshold
|
d["min_brightness_threshold"] = self.min_brightness_threshold
|
||||||
|
d["adaptive_fps"] = self.adaptive_fps
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -116,6 +122,7 @@ class WledPictureTarget(PictureTarget):
|
|||||||
keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)),
|
keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)),
|
||||||
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
||||||
min_brightness_threshold=data.get("min_brightness_threshold", 0),
|
min_brightness_threshold=data.get("min_brightness_threshold", 0),
|
||||||
|
adaptive_fps=data.get("adaptive_fps", False),
|
||||||
description=data.get("description"),
|
description=data.get("description"),
|
||||||
auto_start=data.get("auto_start", False),
|
auto_start=data.get("auto_start", False),
|
||||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||||
|
|||||||
@@ -27,6 +27,52 @@
|
|||||||
<button class="btn btn-danger" onclick="document.getElementById('settings-restore-input').click()" style="width:100%" data-i18n="settings.restore.button">Restore from Backup</button>
|
<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>
|
||||||
|
|
||||||
|
<!-- Auto-Backup section -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="settings.auto_backup.label">Auto-Backup</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="settings.auto_backup.hint">Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.</small>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.5rem;">
|
||||||
|
<input type="checkbox" id="auto-backup-enabled">
|
||||||
|
<label for="auto-backup-enabled" style="margin:0" data-i18n="settings.auto_backup.enable">Enable auto-backup</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
|
||||||
|
<div style="flex:1">
|
||||||
|
<label for="auto-backup-interval" style="font-size:0.85rem" data-i18n="settings.auto_backup.interval_label">Interval</label>
|
||||||
|
<select id="auto-backup-interval" style="width:100%">
|
||||||
|
<option value="1">1h</option>
|
||||||
|
<option value="6">6h</option>
|
||||||
|
<option value="12">12h</option>
|
||||||
|
<option value="24">24h</option>
|
||||||
|
<option value="48">48h</option>
|
||||||
|
<option value="168">7d</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<label for="auto-backup-max" style="font-size:0.85rem" data-i18n="settings.auto_backup.max_label">Max backups</label>
|
||||||
|
<input type="number" id="auto-backup-max" min="1" max="100" value="10" style="width:100%">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" onclick="saveAutoBackupSettings()" style="width:100%" data-i18n="settings.auto_backup.save">Save Settings</button>
|
||||||
|
|
||||||
|
<div id="auto-backup-status" style="font-size:0.85rem; color:var(--text-muted); margin-top:0.5rem;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Saved Backups section -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="settings.saved_backups.label">Saved Backups</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="settings.saved_backups.hint">Auto-backup files stored on the server. Download to save locally, or delete to free space.</small>
|
||||||
|
<div id="saved-backups-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="settings-error" class="error-message" style="display:none;"></div>
|
<div id="settings-error" class="error-message" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,6 +84,18 @@
|
|||||||
<input type="range" id="target-editor-keepalive-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-keepalive-interval-value').textContent = this.value">
|
<input type="range" id="target-editor-keepalive-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-keepalive-interval-value').textContent = this.value">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="target-editor-adaptive-fps-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="target-editor-adaptive-fps" data-i18n="targets.adaptive_fps">Adaptive FPS:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="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.</small>
|
||||||
|
<label class="settings-toggle">
|
||||||
|
<input type="checkbox" id="target-editor-adaptive-fps">
|
||||||
|
<span class="settings-toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="target-editor-error" class="error-message" style="display: none;"></div>
|
<div id="target-editor-error" class="error-message" style="display: none;"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user