feat: add weather source entity and weather-reactive CSS source type
Lint & Test / test (push) Failing after 34s
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:
@@ -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)")
|
||||
Reference in New Issue
Block a user