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.profile_store import ProfileStore
from wled_controller.core.profiles.profile_engine import ProfileEngine
from wled_controller.core.backup.auto_backup import AutoBackupEngine
# Global instances (initialized in main.py)
_auto_backup_engine: AutoBackupEngine | None = None
_device_store: DeviceStore | None = None
_template_store: TemplateStore | None = None
_pp_template_store: PostprocessingTemplateStore | None = None
@@ -121,6 +123,13 @@ def get_profile_engine() -> ProfileEngine:
return _profile_engine
def get_auto_backup_engine() -> AutoBackupEngine:
"""Get auto-backup engine dependency."""
if _auto_backup_engine is None:
raise RuntimeError("Auto-backup engine not initialized")
return _auto_backup_engine
def init_dependencies(
device_store: DeviceStore,
template_store: TemplateStore,
@@ -135,12 +144,13 @@ def init_dependencies(
value_source_store: ValueSourceStore | None = None,
profile_store: ProfileStore | None = None,
profile_engine: ProfileEngine | None = None,
auto_backup_engine: AutoBackupEngine | None = None,
):
"""Initialize global dependencies."""
global _device_store, _template_store, _processor_manager
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
global _color_strip_store, _audio_source_store, _audio_template_store
global _value_source_store, _profile_store, _profile_engine
global _value_source_store, _profile_store, _profile_engine, _auto_backup_engine
_device_store = device_store
_template_store = template_store
_processor_manager = processor_manager
@@ -154,3 +164,4 @@ def init_dependencies(
_value_source_store = value_source_store
_profile_store = profile_store
_profile_engine = profile_engine
_auto_backup_engine = auto_backup_engine

View File

@@ -101,6 +101,7 @@ def _target_to_response(target) -> PictureTargetResponse:
keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval,
min_brightness_threshold=target.min_brightness_threshold,
adaptive_fps=target.adaptive_fps,
description=target.description,
auto_start=target.auto_start,
created_at=target.created_at,
@@ -161,6 +162,7 @@ async def create_target(
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
picture_source_id=data.picture_source_id,
key_colors_settings=kc_settings,
description=data.description,
@@ -279,6 +281,7 @@ async def update_target(
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
key_colors_settings=kc_settings,
description=data.description,
auto_start=data.auto_start,
@@ -299,6 +302,7 @@ async def update_target(
data.keepalive_interval is not None or
data.state_check_interval is not None or
data.min_brightness_threshold is not None or
data.adaptive_fps is not None or
data.key_colors_settings is not None),
css_changed=data.color_strip_source_id is not None,
device_changed=data.device_id is not None,

View File

@@ -16,8 +16,12 @@ from pydantic import BaseModel
from wled_controller import __version__
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_processor_manager
from wled_controller.api.dependencies import get_auto_backup_engine, get_processor_manager
from wled_controller.api.schemas.system import (
AutoBackupSettings,
AutoBackupStatusResponse,
BackupFileInfo,
BackupListResponse,
DisplayInfo,
DisplayListResponse,
GpuInfo,
@@ -27,6 +31,7 @@ from wled_controller.api.schemas.system import (
RestoreResponse,
VersionResponse,
)
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.config import get_config
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.utils import atomic_write_json, get_logger
@@ -348,6 +353,93 @@ async def restore_config(
)
# ---------------------------------------------------------------------------
# Auto-backup settings & saved backups
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/system/auto-backup/settings",
response_model=AutoBackupStatusResponse,
tags=["System"],
)
async def get_auto_backup_settings(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Get auto-backup settings and status."""
return engine.get_settings()
@router.put(
"/api/v1/system/auto-backup/settings",
response_model=AutoBackupStatusResponse,
tags=["System"],
)
async def update_auto_backup_settings(
_: AuthRequired,
body: AutoBackupSettings,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Update auto-backup settings (enable/disable, interval, max backups)."""
return await engine.update_settings(
enabled=body.enabled,
interval_hours=body.interval_hours,
max_backups=body.max_backups,
)
@router.get(
"/api/v1/system/backups",
response_model=BackupListResponse,
tags=["System"],
)
async def list_backups(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""List all saved backup files."""
backups = engine.list_backups()
return BackupListResponse(
backups=[BackupFileInfo(**b) for b in backups],
count=len(backups),
)
@router.get("/api/v1/system/backups/{filename}", tags=["System"])
def download_saved_backup(
filename: str,
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Download a specific saved backup file."""
try:
path = engine.get_backup_path(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
content = path.read_bytes()
return StreamingResponse(
io.BytesIO(content),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.delete("/api/v1/system/backups/{filename}", tags=["System"])
async def delete_saved_backup(
filename: str,
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Delete a specific saved backup file."""
try:
engine.delete_backup(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "deleted", "filename": filename}
# ---------------------------------------------------------------------------
# ADB helpers (for Android / scrcpy engine)
# ---------------------------------------------------------------------------

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)
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
min_brightness_threshold: int = Field(default=0, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off")
adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive")
# KC target fields
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
@@ -78,6 +79,7 @@ class PictureTargetUpdate(BaseModel):
keepalive_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
min_brightness_threshold: Optional[int] = Field(None, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off")
adaptive_fps: Optional[bool] = Field(None, description="Auto-reduce FPS when device is unresponsive")
# KC target fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
@@ -99,6 +101,7 @@ class PictureTargetResponse(BaseModel):
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
min_brightness_threshold: int = Field(default=0, description="Min brightness threshold (0=disabled)")
adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive")
# KC target fields
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")
@@ -152,6 +155,8 @@ class TargetProcessingState(BaseModel):
device_fps: Optional[int] = Field(None, description="Device-reported FPS (WLED internal refresh rate)")
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")
device_streaming_reachable: Optional[bool] = Field(None, description="Device reachable during streaming (HTTP probe)")
fps_effective: Optional[int] = Field(None, description="Effective FPS after adaptive reduction")
class TargetMetricsResponse(BaseModel):

View File

@@ -79,3 +79,38 @@ class RestoreResponse(BaseModel):
missing_stores: List[str] = Field(default_factory=list, description="Store keys not found in backup")
restart_scheduled: bool = Field(description="Whether server restart was scheduled")
message: str = Field(description="Human-readable status message")
# ─── Auto-backup schemas ──────────────────────────────────────
class AutoBackupSettings(BaseModel):
"""Settings for automatic backup."""
enabled: bool = Field(description="Whether auto-backup is enabled")
interval_hours: float = Field(ge=0.5, le=168, description="Backup interval in hours")
max_backups: int = Field(ge=1, le=100, description="Maximum number of backup files to keep")
class AutoBackupStatusResponse(BaseModel):
"""Auto-backup settings plus runtime status."""
enabled: bool
interval_hours: float
max_backups: int
last_backup_time: str | None = None
next_backup_time: str | None = None
class BackupFileInfo(BaseModel):
"""Information about a saved backup file."""
filename: str
size_bytes: int
created_at: str
class BackupListResponse(BaseModel):
"""List of saved backup files."""
backups: List[BackupFileInfo]
count: int