feat: add gradient entity with CRUD API and storage
Reusable gradient definitions with built-in presets (rainbow, sunset, ocean, etc.) and user-created gradients. Includes model, JSON store, Pydantic schemas, REST routes (list/create/update/clone/delete), and backup/restore integration.
This commit is contained in:
@@ -23,6 +23,7 @@ from .routes.scene_presets import router as scene_presets_router
|
||||
from .routes.webhooks import router as webhooks_router
|
||||
from .routes.sync_clocks import router as sync_clocks_router
|
||||
from .routes.color_strip_processing import router as cspt_router
|
||||
from .routes.gradients import router as gradients_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
@@ -46,5 +47,6 @@ router.include_router(scene_presets_router)
|
||||
router.include_router(webhooks_router)
|
||||
router.include_router(sync_clocks_router)
|
||||
router.include_router(cspt_router)
|
||||
router.include_router(gradients_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -21,6 +21,7 @@ from wled_controller.storage.automation_store import AutomationStore
|
||||
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||
from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
from wled_controller.storage.gradient_store import GradientStore
|
||||
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||
@@ -114,6 +115,10 @@ def get_cspt_store() -> ColorStripProcessingTemplateStore:
|
||||
return _get("cspt_store", "Color strip processing template store")
|
||||
|
||||
|
||||
def get_gradient_store() -> GradientStore:
|
||||
return _get("gradient_store", "Gradient store")
|
||||
|
||||
|
||||
# ── Event helper ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -157,6 +162,7 @@ def init_dependencies(
|
||||
sync_clock_store: SyncClockStore | None = None,
|
||||
sync_clock_manager: SyncClockManager | None = None,
|
||||
cspt_store: ColorStripProcessingTemplateStore | None = None,
|
||||
gradient_store: GradientStore | None = None,
|
||||
):
|
||||
"""Initialize global dependencies."""
|
||||
_deps.update({
|
||||
@@ -178,4 +184,5 @@ def init_dependencies(
|
||||
"sync_clock_store": sync_clock_store,
|
||||
"sync_clock_manager": sync_clock_manager,
|
||||
"cspt_store": cspt_store,
|
||||
"gradient_store": gradient_store,
|
||||
})
|
||||
|
||||
@@ -54,6 +54,7 @@ STORE_MAP = {
|
||||
"color_strip_processing_templates": "color_strip_processing_templates_file",
|
||||
"automations": "automations_file",
|
||||
"scene_presets": "scene_presets_file",
|
||||
"gradients": "gradients_file",
|
||||
}
|
||||
|
||||
_SERVER_DIR = Path(__file__).resolve().parents[4]
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Gradient routes: CRUD for reusable gradient definitions."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_color_strip_store,
|
||||
get_gradient_store,
|
||||
)
|
||||
from wled_controller.api.schemas.gradients import (
|
||||
GradientCreate,
|
||||
GradientListResponse,
|
||||
GradientResponse,
|
||||
GradientUpdate,
|
||||
)
|
||||
from wled_controller.storage.gradient import Gradient
|
||||
from wled_controller.storage.gradient_store import GradientStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_response(gradient: Gradient) -> GradientResponse:
|
||||
return GradientResponse(
|
||||
id=gradient.id,
|
||||
name=gradient.name,
|
||||
stops=[{"position": s["position"], "color": s["color"]} for s in gradient.stops],
|
||||
is_builtin=gradient.is_builtin,
|
||||
description=gradient.description,
|
||||
tags=gradient.tags,
|
||||
created_at=gradient.created_at,
|
||||
updated_at=gradient.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/gradients", response_model=GradientListResponse, tags=["Gradients"])
|
||||
async def list_gradients(
|
||||
_auth: AuthRequired,
|
||||
store: GradientStore = Depends(get_gradient_store),
|
||||
):
|
||||
"""List all gradients (built-in + user-created)."""
|
||||
gradients = store.get_all_gradients()
|
||||
return GradientListResponse(
|
||||
gradients=[_to_response(g) for g in gradients],
|
||||
count=len(gradients),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/gradients", response_model=GradientResponse, status_code=201, tags=["Gradients"])
|
||||
async def create_gradient(
|
||||
data: GradientCreate,
|
||||
_auth: AuthRequired,
|
||||
store: GradientStore = Depends(get_gradient_store),
|
||||
):
|
||||
"""Create a new user-defined gradient."""
|
||||
try:
|
||||
gradient = store.create_gradient(
|
||||
name=data.name,
|
||||
stops=[s.model_dump() for s in data.stops],
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("gradient", "created", gradient.id)
|
||||
return _to_response(gradient)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/gradients/{gradient_id}", response_model=GradientResponse, tags=["Gradients"])
|
||||
async def get_gradient(
|
||||
gradient_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: GradientStore = Depends(get_gradient_store),
|
||||
):
|
||||
"""Get a gradient by ID."""
|
||||
try:
|
||||
gradient = store.get_gradient(gradient_id)
|
||||
return _to_response(gradient)
|
||||
except (ValueError, EntityNotFoundError) as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/v1/gradients/{gradient_id}", response_model=GradientResponse, tags=["Gradients"])
|
||||
async def update_gradient(
|
||||
gradient_id: str,
|
||||
data: GradientUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: GradientStore = Depends(get_gradient_store),
|
||||
):
|
||||
"""Update a gradient (built-in gradients are read-only)."""
|
||||
try:
|
||||
stops = [s.model_dump() for s in data.stops] if data.stops is not None else None
|
||||
gradient = store.update_gradient(
|
||||
gradient_id=gradient_id,
|
||||
name=data.name,
|
||||
stops=stops,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("gradient", "updated", gradient_id)
|
||||
return _to_response(gradient)
|
||||
except (ValueError, EntityNotFoundError) as e:
|
||||
status = 404 if "not found" in str(e).lower() else 400
|
||||
raise HTTPException(status_code=status, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/gradients/{gradient_id}/clone", response_model=GradientResponse, status_code=201, tags=["Gradients"])
|
||||
async def clone_gradient(
|
||||
gradient_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: GradientStore = Depends(get_gradient_store),
|
||||
):
|
||||
"""Clone a gradient (useful for customizing built-in gradients)."""
|
||||
try:
|
||||
original = store.get_gradient(gradient_id)
|
||||
clone = store.create_gradient(
|
||||
name=f"{original.name} (copy)",
|
||||
stops=original.stops,
|
||||
description=original.description,
|
||||
tags=original.tags,
|
||||
)
|
||||
fire_entity_event("gradient", "created", clone.id)
|
||||
return _to_response(clone)
|
||||
except (ValueError, EntityNotFoundError) as e:
|
||||
status = 404 if "not found" in str(e).lower() else 400
|
||||
raise HTTPException(status_code=status, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/v1/gradients/{gradient_id}", status_code=204, tags=["Gradients"])
|
||||
async def delete_gradient(
|
||||
gradient_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: GradientStore = Depends(get_gradient_store),
|
||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||
):
|
||||
"""Delete a gradient (fails if built-in or referenced by sources)."""
|
||||
try:
|
||||
# Check references
|
||||
for source in css_store.get_all_sources():
|
||||
if getattr(source, "gradient_id", None) == gradient_id:
|
||||
raise ValueError(
|
||||
f"Cannot delete: referenced by color strip source '{source.name}'"
|
||||
)
|
||||
store.delete_gradient(gradient_id)
|
||||
fire_entity_event("gradient", "deleted", gradient_id)
|
||||
except (ValueError, EntityNotFoundError) as e:
|
||||
status = 404 if "not found" in str(e).lower() else 400
|
||||
raise HTTPException(status_code=status, detail=str(e))
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Gradient schemas (CRUD)."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class GradientStopSchema(BaseModel):
|
||||
"""A single gradient color stop."""
|
||||
|
||||
position: float = Field(description="Position along gradient (0.0-1.0)", ge=0.0, le=1.0)
|
||||
color: List[int] = Field(description="RGB color [R, G, B]", min_length=3, max_length=3)
|
||||
|
||||
|
||||
class GradientCreate(BaseModel):
|
||||
"""Request to create a gradient."""
|
||||
|
||||
name: str = Field(description="Gradient name", min_length=1, max_length=100)
|
||||
stops: List[GradientStopSchema] = Field(description="Color stops", min_length=2)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class GradientUpdate(BaseModel):
|
||||
"""Request to update a gradient."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Gradient name", min_length=1, max_length=100)
|
||||
stops: Optional[List[GradientStopSchema]] = Field(None, description="Color stops", min_length=2)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class GradientResponse(BaseModel):
|
||||
"""Gradient response."""
|
||||
|
||||
id: str = Field(description="Gradient ID")
|
||||
name: str = Field(description="Gradient name")
|
||||
stops: List[GradientStopSchema] = Field(description="Color stops")
|
||||
is_builtin: bool = Field(description="Whether this is a built-in gradient")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
class GradientListResponse(BaseModel):
|
||||
"""List of gradients."""
|
||||
|
||||
gradients: List[GradientResponse] = Field(description="List of gradients")
|
||||
count: int = Field(description="Number of gradients")
|
||||
Reference in New Issue
Block a user