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.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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user