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:
2026-02-26 20:22:58 +03:00
parent f8656b72a6
commit cadef971e7
23 changed files with 873 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'}`;
}
} }
} }
} }

View File

@@ -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;">&#x21BA;</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;">&#x2B07;</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;">&#x2715;</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');
}
}

View File

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

View File

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

View File

@@ -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": "Не удалось удалить копию"
} }

View File

@@ -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": "删除备份失败"
} }

View File

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

View File

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

View File

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

View File

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