feat: add weather source entity and weather-reactive CSS source type
Lint & Test / test (push) Failing after 34s

New standalone WeatherSource entity with pluggable provider architecture
(Open-Meteo v1, free, no API key). Full CRUD, test endpoint, browser
geolocation, IconSelect provider picker, CardSection with test/clone/edit.

WeatherColorStripStream maps WMO weather codes to ambient color palettes
with temperature hue shifting and thunderstorm flash effects. Ref-counted
WeatherManager polls API and caches data per source.

CSS editor integration: weather type with EntitySelect source picker,
speed and temperature influence sliders. Backup/restore support.

i18n for en/ru/zh.
This commit is contained in:
2026-03-24 18:52:46 +03:00
parent 0723c5c68c
commit ef33935188
31 changed files with 1868 additions and 11 deletions
@@ -24,6 +24,7 @@ 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
from .routes.weather_sources import router as weather_sources_router
router = APIRouter()
router.include_router(system_router)
@@ -48,5 +49,6 @@ router.include_router(webhooks_router)
router.include_router(sync_clocks_router)
router.include_router(cspt_router)
router.include_router(gradients_router)
router.include_router(weather_sources_router)
__all__ = ["router"]
@@ -22,7 +22,9 @@ 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.storage.weather_source_store import WeatherSourceStore
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
@@ -119,6 +121,14 @@ def get_gradient_store() -> GradientStore:
return _get("gradient_store", "Gradient store")
def get_weather_source_store() -> WeatherSourceStore:
return _get("weather_source_store", "Weather source store")
def get_weather_manager() -> WeatherManager:
return _get("weather_manager", "Weather manager")
# ── Event helper ────────────────────────────────────────────────────────
@@ -163,6 +173,8 @@ def init_dependencies(
sync_clock_manager: SyncClockManager | None = None,
cspt_store: ColorStripProcessingTemplateStore | None = None,
gradient_store: GradientStore | None = None,
weather_source_store: WeatherSourceStore | None = None,
weather_manager: WeatherManager | None = None,
):
"""Initialize global dependencies."""
_deps.update({
@@ -185,4 +197,6 @@ def init_dependencies(
"sync_clock_manager": sync_clock_manager,
"cspt_store": cspt_store,
"gradient_store": gradient_store,
"weather_source_store": weather_source_store,
"weather_manager": weather_manager,
})
@@ -55,6 +55,7 @@ STORE_MAP = {
"automations": "automations_file",
"scene_presets": "scene_presets_file",
"gradients": "gradients_file",
"weather_sources": "weather_sources_file",
}
_SERVER_DIR = Path(__file__).resolve().parents[4]
@@ -0,0 +1,157 @@
"""Weather source routes: CRUD + test endpoint."""
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_weather_manager,
get_weather_source_store,
)
from wled_controller.api.schemas.weather_sources import (
WeatherSourceCreate,
WeatherSourceListResponse,
WeatherSourceResponse,
WeatherSourceUpdate,
WeatherTestResponse,
)
from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.core.weather.weather_provider import WMO_CONDITION_NAMES
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.weather_source import WeatherSource
from wled_controller.storage.weather_source_store import WeatherSourceStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
def _to_response(source: WeatherSource) -> WeatherSourceResponse:
d = source.to_dict()
return WeatherSourceResponse(
id=d["id"],
name=d["name"],
provider=d["provider"],
provider_config=d.get("provider_config", {}),
latitude=d["latitude"],
longitude=d["longitude"],
update_interval=d["update_interval"],
description=d.get("description"),
tags=d.get("tags", []),
created_at=source.created_at,
updated_at=source.updated_at,
)
@router.get("/api/v1/weather-sources", response_model=WeatherSourceListResponse, tags=["Weather Sources"])
async def list_weather_sources(
_auth: AuthRequired,
store: WeatherSourceStore = Depends(get_weather_source_store),
):
sources = store.get_all_sources()
return WeatherSourceListResponse(
sources=[_to_response(s) for s in sources],
count=len(sources),
)
@router.post("/api/v1/weather-sources", response_model=WeatherSourceResponse, status_code=201, tags=["Weather Sources"])
async def create_weather_source(
data: WeatherSourceCreate,
_auth: AuthRequired,
store: WeatherSourceStore = Depends(get_weather_source_store),
):
try:
source = store.create_source(
name=data.name,
provider=data.provider,
provider_config=data.provider_config,
latitude=data.latitude,
longitude=data.longitude,
update_interval=data.update_interval,
description=data.description,
tags=data.tags,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("weather_source", "created", source.id)
return _to_response(source)
@router.get("/api/v1/weather-sources/{source_id}", response_model=WeatherSourceResponse, tags=["Weather Sources"])
async def get_weather_source(
source_id: str,
_auth: AuthRequired,
store: WeatherSourceStore = Depends(get_weather_source_store),
):
try:
return _to_response(store.get_source(source_id))
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found")
@router.put("/api/v1/weather-sources/{source_id}", response_model=WeatherSourceResponse, tags=["Weather Sources"])
async def update_weather_source(
source_id: str,
data: WeatherSourceUpdate,
_auth: AuthRequired,
store: WeatherSourceStore = Depends(get_weather_source_store),
manager: WeatherManager = Depends(get_weather_manager),
):
try:
source = store.update_source(
source_id,
name=data.name,
provider=data.provider,
provider_config=data.provider_config,
latitude=data.latitude,
longitude=data.longitude,
update_interval=data.update_interval,
description=data.description,
tags=data.tags,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
manager.update_source(source_id)
fire_entity_event("weather_source", "updated", source.id)
return _to_response(source)
@router.delete("/api/v1/weather-sources/{source_id}", status_code=204, tags=["Weather Sources"])
async def delete_weather_source(
source_id: str,
_auth: AuthRequired,
store: WeatherSourceStore = Depends(get_weather_source_store),
):
try:
store.delete_source(source_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found")
fire_entity_event("weather_source", "deleted", source_id)
@router.post("/api/v1/weather-sources/{source_id}/test", response_model=WeatherTestResponse, tags=["Weather Sources"])
async def test_weather_source(
source_id: str,
_auth: AuthRequired,
store: WeatherSourceStore = Depends(get_weather_source_store),
manager: WeatherManager = Depends(get_weather_manager),
):
"""Force-fetch current weather and return the result."""
try:
store.get_source(source_id) # validate exists
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found")
data = manager.fetch_now(source_id)
condition = WMO_CONDITION_NAMES.get(data.code, f"Unknown ({data.code})")
return WeatherTestResponse(
code=data.code,
condition=condition,
temperature=data.temperature,
wind_speed=data.wind_speed,
cloud_cover=data.cloud_cover,
)
@@ -54,7 +54,7 @@ class ColorStripSourceCreate(BaseModel):
"""Request to create a color strip source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight", "processed"] = Field(default="picture", description="Source type")
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight", "processed", "weather"] = Field(default="picture", description="Source type")
# picture-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0)
@@ -113,6 +113,9 @@ class ColorStripSourceCreate(BaseModel):
# processed-type fields
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
# weather-type fields
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID (for weather type)")
temperature_influence: Optional[float] = Field(None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0)
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -180,6 +183,9 @@ class ColorStripSourceUpdate(BaseModel):
# processed-type fields
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
# weather-type fields
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID (for weather type)")
temperature_influence: Optional[float] = Field(None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0)
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
tags: Optional[List[str]] = None
@@ -248,6 +254,9 @@ class ColorStripSourceResponse(BaseModel):
# processed-type fields
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID")
# weather-type fields
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID")
temperature_influence: Optional[float] = Field(None, description="Temperature color shift strength")
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -0,0 +1,65 @@
"""Weather source schemas (CRUD)."""
from datetime import datetime
from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field
class WeatherSourceCreate(BaseModel):
"""Request to create a weather source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
provider: Literal["open_meteo"] = Field(default="open_meteo", description="Weather data provider")
provider_config: Optional[Dict] = Field(None, description="Provider-specific configuration")
latitude: float = Field(default=50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: float = Field(default=0.0, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0)
update_interval: int = Field(default=600, description="API poll interval in seconds (60-3600)", ge=60, le=3600)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class WeatherSourceUpdate(BaseModel):
"""Request to update a weather source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
provider: Optional[Literal["open_meteo"]] = Field(None, description="Weather data provider")
provider_config: Optional[Dict] = Field(None, description="Provider-specific configuration")
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(None, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0)
update_interval: Optional[int] = Field(None, description="API poll interval in seconds (60-3600)", ge=60, le=3600)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class WeatherSourceResponse(BaseModel):
"""Weather source response."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
provider: str = Field(description="Weather data provider")
provider_config: Dict = Field(default_factory=dict, description="Provider-specific configuration")
latitude: float = Field(description="Geographic latitude")
longitude: float = Field(description="Geographic longitude")
update_interval: int = Field(description="API poll interval in seconds")
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 WeatherSourceListResponse(BaseModel):
"""List of weather sources."""
sources: List[WeatherSourceResponse] = Field(description="List of weather sources")
count: int = Field(description="Number of sources")
class WeatherTestResponse(BaseModel):
"""Weather test/fetch result."""
code: int = Field(description="WMO weather code")
condition: str = Field(description="Human-readable condition name")
temperature: float = Field(description="Temperature in Celsius")
wind_speed: float = Field(description="Wind speed in km/h")
cloud_cover: int = Field(description="Cloud cover percentage (0-100)")