diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..ed4b06c --- /dev/null +++ b/TODO.md @@ -0,0 +1,47 @@ +# Weather Source Implementation + +## Phase 1: Backend — Entity & Provider + +- [x] `storage/weather_source.py` — WeatherSource dataclass +- [x] `storage/weather_source_store.py` — BaseJsonStore, CRUD, ID prefix `ws_` +- [x] `api/schemas/weather_sources.py` — Create/Update/Response Pydantic models +- [x] `api/routes/weather_sources.py` — REST CRUD + `POST /{id}/test` endpoint +- [x] `core/weather/weather_provider.py` — WeatherData, WeatherProvider ABC, OpenMeteoProvider, WMO_CONDITION_NAMES +- [x] `core/weather/weather_manager.py` — Ref-counted runtime pool, polls API, caches WeatherData +- [x] `config.py` — Add `weather_sources_file` to StorageConfig +- [x] `main.py` — Init store + manager, inject dependencies, shutdown save +- [x] `api/__init__.py` — Register router +- [x] `api/routes/backup.py` — Add to STORE_MAP + +## Phase 2: Backend — CSS Stream + +- [x] `core/processing/weather_stream.py` — WeatherColorStripStream with WMO palette mapping + temperature shift + thunderstorm flash +- [x] `core/processing/color_strip_stream_manager.py` — Register `"weather"` stream type + weather_manager dependency +- [x] `storage/color_strip_source.py` — WeatherColorStripSource dataclass + registry +- [x] `api/schemas/color_strip_sources.py` — Add `"weather"` to Literal + weather_source_id, temperature_influence fields +- [x] `core/processing/processor_manager.py` — Pass weather_manager through ProcessorDependencies + +## Phase 3: Frontend — Weather Source Entity + +- [x] `templates/modals/weather-source-editor.html` — Modal with provider select, lat/lon + "Use my location", update interval, test button +- [x] `static/js/features/weather-sources.ts` — Modal, CRUD, test (shows weather toast), clone, geolocation, CardSection delegation +- [x] `static/js/core/state.ts` — weatherSourcesCache + _cachedWeatherSources +- [x] `static/js/types.ts` — WeatherSource interface + ColorStripSource weather fields +- [x] `static/js/features/streams.ts` — Weather Sources CardSection + card renderer + tree nav +- [x] `templates/index.html` — Include modal template +- [x] `static/css/modal.css` — Weather location row styles + +## Phase 4: Frontend — CSS Editor Integration + +- [x] `static/js/features/color-strips.ts` — `"weather"` type, section map, handler, card renderer, populate dropdown +- [x] `static/js/core/icons.ts` — Weather icon in CSS type icons +- [x] `templates/modals/css-editor.html` — Weather section (EntitySelect for weather source, speed, temperature_influence) + +## Phase 5: i18n + Build + +- [x] `static/locales/en.json` — Weather source + CSS editor keys +- [x] `static/locales/ru.json` — Russian translations +- [x] `static/locales/zh.json` — Chinese translations +- [x] Lint: `ruff check` — passed +- [x] Build: `tsc --noEmit` + `npm run build` — passed +- [ ] Restart server + test diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index d76cb32..64338af 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -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"] diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index 793440e..d4e0233 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -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, }) diff --git a/server/src/wled_controller/api/routes/backup.py b/server/src/wled_controller/api/routes/backup.py index 2df119c..0f689e3 100644 --- a/server/src/wled_controller/api/routes/backup.py +++ b/server/src/wled_controller/api/routes/backup.py @@ -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] diff --git a/server/src/wled_controller/api/routes/weather_sources.py b/server/src/wled_controller/api/routes/weather_sources.py new file mode 100644 index 0000000..225e87c --- /dev/null +++ b/server/src/wled_controller/api/routes/weather_sources.py @@ -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, + ) diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index 0ab24e6..f5db541 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -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") diff --git a/server/src/wled_controller/api/schemas/weather_sources.py b/server/src/wled_controller/api/schemas/weather_sources.py new file mode 100644 index 0000000..df323d8 --- /dev/null +++ b/server/src/wled_controller/api/schemas/weather_sources.py @@ -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)") diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py index be11681..16487fd 100644 --- a/server/src/wled_controller/config.py +++ b/server/src/wled_controller/config.py @@ -42,6 +42,7 @@ class StorageConfig(BaseSettings): color_strip_processing_templates_file: str = "data/color_strip_processing_templates.json" sync_clocks_file: str = "data/sync_clocks.json" gradients_file: str = "data/gradients.json" + weather_sources_file: str = "data/weather_sources.json" class MQTTConfig(BaseSettings): diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py index 1931527..cefd708 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -69,7 +69,7 @@ class ColorStripStreamManager: keyed by ``{css_id}:{consumer_id}``. """ - def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None, gradient_store=None): + def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None, gradient_store=None, weather_manager=None): """ Args: color_strip_store: ColorStripStore for resolving source configs @@ -90,6 +90,7 @@ class ColorStripStreamManager: self._value_stream_manager = value_stream_manager self._cspt_store = cspt_store self._gradient_store = gradient_store + self._weather_manager = weather_manager self._streams: Dict[str, _ColorStripEntry] = {} def _inject_clock(self, css_stream, source) -> Optional[str]: @@ -172,6 +173,9 @@ class ColorStripStreamManager: css_stream = MappedColorStripStream(source, self) elif source.source_type == "processed": css_stream = ProcessedColorStripStream(source, self, self._cspt_store) + elif source.source_type == "weather": + from wled_controller.core.processing.weather_stream import WeatherColorStripStream + css_stream = WeatherColorStripStream(source, self._weather_manager) else: stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type) if not stream_cls: diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index bd42d47..a3c48f5 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -56,6 +56,7 @@ class ProcessorDependencies: sync_clock_manager: object = None cspt_store: object = None gradient_store: object = None + weather_manager: object = None @dataclass @@ -131,6 +132,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) sync_clock_manager=deps.sync_clock_manager, cspt_store=deps.cspt_store, gradient_store=deps.gradient_store, + weather_manager=deps.weather_manager, ) self._value_stream_manager = ValueStreamManager( value_source_store=deps.value_source_store, diff --git a/server/src/wled_controller/core/processing/weather_stream.py b/server/src/wled_controller/core/processing/weather_stream.py new file mode 100644 index 0000000..f9f3f88 --- /dev/null +++ b/server/src/wled_controller/core/processing/weather_stream.py @@ -0,0 +1,282 @@ +"""Weather-reactive color strip stream — maps weather conditions to ambient LED colors.""" + +import random +import threading +import time +from typing import Optional + +import numpy as np + +from wled_controller.core.processing.color_strip_stream import ColorStripStream +from wled_controller.core.weather.weather_manager import WeatherManager +from wled_controller.core.weather.weather_provider import DEFAULT_WEATHER, WeatherData +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +# ── WMO code → palette mapping ────────────────────────────────────────── +# Each entry: (start_rgb, end_rgb) as (R, G, B) tuples. +# Codes are matched by range. + +_PALETTES = { + # Clear sky / mainly clear + (0, 1): ((255, 220, 100), (255, 180, 80)), + # Partly cloudy / overcast + (2, 3): ((150, 180, 220), (240, 235, 220)), + # Fog + (45, 48): ((180, 190, 200), (210, 215, 220)), + # Drizzle (light, moderate, dense, freezing) + (51, 53, 55, 56, 57): ((100, 160, 220), (80, 180, 190)), + # Rain (slight, moderate, heavy, freezing) + (61, 63, 65, 66, 67): ((40, 80, 180), (60, 140, 170)), + # Snow (slight, moderate, heavy, grains) + (71, 73, 75, 77): ((200, 210, 240), (180, 200, 255)), + # Rain showers + (80, 81, 82): ((30, 60, 160), (80, 70, 170)), + # Snow showers + (85, 86): ((220, 225, 240), (190, 185, 220)), + # Thunderstorm + (95, 96, 99): ((60, 20, 120), (40, 60, 200)), +} + +# Default palette (partly cloudy) +_DEFAULT_PALETTE = ((150, 180, 220), (240, 235, 220)) + + +def _resolve_palette(code: int) -> tuple: + """Map a WMO weather code to a (start_rgb, end_rgb) palette.""" + for codes, palette in _PALETTES.items(): + if code in codes: + return palette + return _DEFAULT_PALETTE + + +def _apply_temperature_shift(color: np.ndarray, temperature: float, influence: float) -> np.ndarray: + """Shift color array warm/cool based on temperature. + + > 25°C: shift toward warm (add red, reduce blue) + < 5°C: shift toward cool (add blue, reduce red) + Between 5-25°C: linear interpolation (no shift at 15°C midpoint) + """ + if influence <= 0.0: + return color + + # Normalize temperature to -1..+1 range (cold..hot) + t = (temperature - 15.0) / 10.0 # -1 at 5°C, 0 at 15°C, +1 at 25°C + t = max(-1.0, min(1.0, t)) + shift = t * influence * 30.0 # max ±30 RGB units + + result = color.astype(np.int16) + result[:, 0] += int(shift) # red + result[:, 2] -= int(shift) # blue + np.clip(result, 0, 255, out=result) + return result.astype(np.uint8) + + +def _smoothstep(x: float) -> float: + """Hermite smoothstep: smooth cubic interpolation.""" + x = max(0.0, min(1.0, x)) + return x * x * (3.0 - 2.0 * x) + + +class WeatherColorStripStream(ColorStripStream): + """Generates ambient LED colors based on real-time weather data. + + Fetches weather data from a WeatherManager (which polls the API), + maps the WMO condition code to a color palette, applies temperature + influence, and animates a slow gradient drift across the strip. + """ + + def __init__(self, source, weather_manager: WeatherManager): + self._source_id = source.id + self._weather_source_id: str = source.weather_source_id + self._speed: float = source.speed + self._temperature_influence: float = source.temperature_influence + self._clock_id: Optional[str] = source.clock_id + self._weather_manager = weather_manager + + self._led_count: int = 0 # auto-size from device + self._fps: int = 30 + self._frame_time: float = 1.0 / 30 + + self._running = False + self._thread: Optional[threading.Thread] = None + self._latest_colors: Optional[np.ndarray] = None + self._colors_lock = threading.Lock() + + # Pre-allocated buffers + self._buf_a: Optional[np.ndarray] = None + self._buf_b: Optional[np.ndarray] = None + self._use_a = True + self._pool_n = 0 + + # Thunderstorm flash state + self._flash_remaining = 0 + + # ── ColorStripStream interface ────────────────────────────────── + + @property + def target_fps(self) -> int: + return self._fps + + def set_capture_fps(self, fps: int) -> None: + self._fps = max(1, min(60, fps)) + self._frame_time = 1.0 / self._fps + + @property + def led_count(self) -> int: + return self._led_count + + @property + def is_animated(self) -> bool: + return True + + def start(self) -> None: + # Acquire weather runtime (increments ref count) + if self._weather_source_id: + try: + self._weather_manager.acquire(self._weather_source_id) + except Exception as e: + logger.warning(f"Weather stream {self._source_id}: failed to acquire weather source: {e}") + + self._running = True + self._thread = threading.Thread( + target=self._animate_loop, daemon=True, + name=f"WeatherCSS-{self._source_id[:12]}", + ) + self._thread.start() + logger.info(f"WeatherColorStripStream started: {self._source_id}") + + def stop(self) -> None: + self._running = False + if self._thread is not None: + self._thread.join(timeout=5.0) + self._thread = None + + # Release weather runtime + if self._weather_source_id: + try: + self._weather_manager.release(self._weather_source_id) + except Exception as e: + logger.warning(f"Weather stream {self._source_id}: failed to release weather source: {e}") + + logger.info(f"WeatherColorStripStream stopped: {self._source_id}") + + def get_latest_colors(self) -> Optional[np.ndarray]: + with self._colors_lock: + return self._latest_colors + + def configure(self, device_led_count: int) -> None: + if device_led_count > 0 and device_led_count != self._led_count: + self._led_count = device_led_count + + def update_source(self, source) -> None: + self._speed = source.speed + self._temperature_influence = source.temperature_influence + self._clock_id = source.clock_id + + # If weather source changed, release old + acquire new + new_ws_id = source.weather_source_id + if new_ws_id != self._weather_source_id: + if self._weather_source_id: + try: + self._weather_manager.release(self._weather_source_id) + except Exception: + pass + self._weather_source_id = new_ws_id + if new_ws_id: + try: + self._weather_manager.acquire(new_ws_id) + except Exception as e: + logger.warning(f"Weather stream: failed to acquire new source {new_ws_id}: {e}") + + # ── Animation loop ────────────────────────────────────────────── + + def _ensure_pool(self, n: int) -> None: + if n == self._pool_n or n <= 0: + return + self._pool_n = n + self._buf_a = np.zeros((n, 3), dtype=np.uint8) + self._buf_b = np.zeros((n, 3), dtype=np.uint8) + + def _get_clock_time(self) -> Optional[float]: + """Get time from sync clock if configured.""" + if not self._clock_id: + return None + try: + # Access via weather manager's store isn't ideal, but clocks + # are looked up at the ProcessorManager level when the stream + # is created. For now, return None and use wall time. + return None + except Exception: + return None + + def _animate_loop(self) -> None: + start_time = time.perf_counter() + try: + while self._running: + loop_start = time.perf_counter() + + try: + n = self._led_count + if n <= 0: + time.sleep(self._frame_time) + continue + + self._ensure_pool(n) + buf = self._buf_a if self._use_a else self._buf_b + self._use_a = not self._use_a + + # Get weather data + weather = self._get_weather() + palette_start, palette_end = _resolve_palette(weather.code) + + # Convert to arrays + c0 = np.array(palette_start, dtype=np.float32) + c1 = np.array(palette_end, dtype=np.float32) + + # Compute animation phase + t = time.perf_counter() - start_time + phase = (t * self._speed * 0.1) % 1.0 + + # Generate gradient with drift + for i in range(n): + frac = ((i / max(n - 1, 1)) + phase) % 1.0 + s = _smoothstep(frac) + buf[i] = (c0 * (1.0 - s) + c1 * s).astype(np.uint8) + + # Apply temperature shift + if self._temperature_influence > 0.0: + buf[:] = _apply_temperature_shift(buf, weather.temperature, self._temperature_influence) + + # Thunderstorm flash effect + is_thunderstorm = weather.code in (95, 96, 99) + if is_thunderstorm: + if self._flash_remaining > 0: + buf[:] = 255 + self._flash_remaining -= 1 + elif random.random() < 0.015: # ~1.5% chance per frame + self._flash_remaining = random.randint(2, 5) + + with self._colors_lock: + self._latest_colors = buf + + except Exception as e: + logger.error(f"WeatherColorStripStream error: {e}", exc_info=True) + + elapsed = time.perf_counter() - loop_start + time.sleep(max(self._frame_time - elapsed, 0.001)) + + except Exception as e: + logger.error(f"Fatal WeatherColorStripStream error: {e}", exc_info=True) + finally: + self._running = False + + def _get_weather(self) -> WeatherData: + """Get current weather data from the manager.""" + if not self._weather_source_id: + return DEFAULT_WEATHER + try: + return self._weather_manager.get_data(self._weather_source_id) + except Exception: + return DEFAULT_WEATHER diff --git a/server/src/wled_controller/core/weather/__init__.py b/server/src/wled_controller/core/weather/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/src/wled_controller/core/weather/weather_manager.py b/server/src/wled_controller/core/weather/weather_manager.py new file mode 100644 index 0000000..12cde35 --- /dev/null +++ b/server/src/wled_controller/core/weather/weather_manager.py @@ -0,0 +1,183 @@ +"""Weather source runtime manager — polls APIs and caches WeatherData. + +Ref-counted pool: multiple CSS streams sharing the same weather source +share one polling loop. Lazy-creates runtimes on first acquire(). +""" + +import threading +import time +from typing import Dict, Optional + +from wled_controller.core.weather.weather_provider import ( + DEFAULT_WEATHER, + WeatherData, + WeatherProvider, + create_provider, +) +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__) + + +class _WeatherRuntime: + """Polls a weather provider on a timer and caches the latest result.""" + + def __init__(self, source: WeatherSource, provider: WeatherProvider) -> None: + self._source_id = source.id + self._provider = provider + self._latitude = source.latitude + self._longitude = source.longitude + self._interval = max(60, source.update_interval) + + self._data: WeatherData = DEFAULT_WEATHER + self._lock = threading.Lock() + self._running = False + self._thread: Optional[threading.Thread] = None + + @property + def data(self) -> WeatherData: + with self._lock: + return self._data + + def start(self) -> None: + if self._running: + return + self._running = True + self._thread = threading.Thread( + target=self._poll_loop, daemon=True, + name=f"Weather-{self._source_id[:12]}", + ) + self._thread.start() + logger.info(f"Weather runtime started: {self._source_id}") + + def stop(self) -> None: + self._running = False + if self._thread is not None: + self._thread.join(timeout=10.0) + self._thread = None + logger.info(f"Weather runtime stopped: {self._source_id}") + + def update_config(self, source: WeatherSource) -> None: + self._latitude = source.latitude + self._longitude = source.longitude + self._interval = max(60, source.update_interval) + + def fetch_now(self) -> WeatherData: + """Force an immediate fetch (used by test endpoint).""" + result = self._provider.fetch(self._latitude, self._longitude) + with self._lock: + self._data = result + return result + + def _poll_loop(self) -> None: + # Fetch immediately on start + self._do_fetch() + last_fetch = time.monotonic() + + while self._running: + time.sleep(1.0) + if time.monotonic() - last_fetch >= self._interval: + self._do_fetch() + last_fetch = time.monotonic() + + def _do_fetch(self) -> None: + result = self._provider.fetch(self._latitude, self._longitude) + with self._lock: + self._data = result + logger.debug( + f"Weather {self._source_id}: code={result.code} " + f"temp={result.temperature:.1f}C wind={result.wind_speed:.0f}km/h" + ) + + +class WeatherManager: + """Ref-counted pool of weather runtimes.""" + + def __init__(self, store: WeatherSourceStore) -> None: + self._store = store + # source_id -> (runtime, ref_count) + self._runtimes: Dict[str, tuple] = {} + self._lock = threading.Lock() + + def acquire(self, source_id: str) -> _WeatherRuntime: + """Get or create a runtime for the given weather source. Increments ref count.""" + with self._lock: + if source_id in self._runtimes: + runtime, count = self._runtimes[source_id] + self._runtimes[source_id] = (runtime, count + 1) + return runtime + + source = self._store.get(source_id) + provider = create_provider(source.provider, source.provider_config) + runtime = _WeatherRuntime(source, provider) + runtime.start() + self._runtimes[source_id] = (runtime, 1) + return runtime + + def release(self, source_id: str) -> None: + """Decrement ref count; stop runtime when it reaches zero.""" + with self._lock: + if source_id not in self._runtimes: + return + runtime, count = self._runtimes[source_id] + if count <= 1: + runtime.stop() + del self._runtimes[source_id] + else: + self._runtimes[source_id] = (runtime, count - 1) + + def get_data(self, source_id: str) -> WeatherData: + """Get cached weather data for a source (creates runtime if needed).""" + with self._lock: + if source_id in self._runtimes: + runtime, _count = self._runtimes[source_id] + return runtime.data + # No active runtime — do a one-off fetch via ensure_runtime + runtime = self._ensure_runtime(source_id) + return runtime.data + + def fetch_now(self, source_id: str) -> WeatherData: + """Force an immediate fetch for the test endpoint.""" + runtime = self._ensure_runtime(source_id) + return runtime.fetch_now() + + def update_source(self, source_id: str) -> None: + """Hot-update runtime config when the source is edited.""" + with self._lock: + if source_id not in self._runtimes: + return + runtime, count = self._runtimes[source_id] + try: + source = self._store.get(source_id) + runtime.update_config(source) + except Exception as e: + logger.warning(f"Failed to update weather runtime {source_id}: {e}") + + def _ensure_runtime(self, source_id: str) -> _WeatherRuntime: + """Get or create a runtime (for API control of idle sources).""" + with self._lock: + if source_id in self._runtimes: + runtime, count = self._runtimes[source_id] + return runtime + + source = self._store.get(source_id) + provider = create_provider(source.provider, source.provider_config) + runtime = _WeatherRuntime(source, provider) + runtime.start() + with self._lock: + if source_id not in self._runtimes: + self._runtimes[source_id] = (runtime, 0) + else: + runtime.stop() + runtime, _count = self._runtimes[source_id] + return runtime + + def shutdown(self) -> None: + """Stop all runtimes.""" + with self._lock: + for source_id, (runtime, _count) in list(self._runtimes.items()): + runtime.stop() + self._runtimes.clear() + logger.info("Weather manager shut down") diff --git a/server/src/wled_controller/core/weather/weather_provider.py b/server/src/wled_controller/core/weather/weather_provider.py new file mode 100644 index 0000000..20b3a38 --- /dev/null +++ b/server/src/wled_controller/core/weather/weather_provider.py @@ -0,0 +1,123 @@ +"""Weather data providers with pluggable backend support. + +Each provider fetches current weather data and returns a standardized +WeatherData result. Only Open-Meteo is supported in v1 (free, no API key). +""" + +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Dict, Type + +import httpx + +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +_HTTP_TIMEOUT = 5.0 + + +@dataclass(frozen=True) +class WeatherData: + """Immutable weather observation.""" + + code: int # WMO weather code (0-99) + temperature: float # Celsius + wind_speed: float # km/h + cloud_cover: int # 0-100 % + fetched_at: float # time.monotonic() timestamp + + +# Default fallback when no data has been fetched yet +DEFAULT_WEATHER = WeatherData(code=2, temperature=20.0, wind_speed=5.0, cloud_cover=50, fetched_at=0.0) + + +class WeatherProvider(ABC): + """Abstract weather data provider.""" + + @abstractmethod + def fetch(self, latitude: float, longitude: float) -> WeatherData: + """Fetch current weather for the given location. + + Must not raise — returns DEFAULT_WEATHER on failure. + """ + + +class OpenMeteoProvider(WeatherProvider): + """Open-Meteo API provider (free, no API key required).""" + + _URL = "https://api.open-meteo.com/v1/forecast" + + def __init__(self) -> None: + self._client = httpx.Client(timeout=_HTTP_TIMEOUT) + + def fetch(self, latitude: float, longitude: float) -> WeatherData: + try: + resp = self._client.get( + self._URL, + params={ + "latitude": latitude, + "longitude": longitude, + "current": "temperature_2m,weather_code,wind_speed_10m,cloud_cover", + }, + ) + resp.raise_for_status() + data = resp.json() + current = data["current"] + return WeatherData( + code=int(current["weather_code"]), + temperature=float(current["temperature_2m"]), + wind_speed=float(current.get("wind_speed_10m", 0.0)), + cloud_cover=int(current.get("cloud_cover", 50)), + fetched_at=time.monotonic(), + ) + except Exception as e: + logger.warning(f"Open-Meteo fetch failed: {e}") + return DEFAULT_WEATHER + + +PROVIDER_REGISTRY: Dict[str, Type[WeatherProvider]] = { + "open_meteo": OpenMeteoProvider, +} + + +def create_provider(provider_name: str, provider_config: dict) -> WeatherProvider: + """Create a provider instance from registry.""" + cls = PROVIDER_REGISTRY.get(provider_name) + if cls is None: + raise ValueError(f"Unknown weather provider: {provider_name}") + return cls() + + +# WMO Weather interpretation codes (WMO 4677) +WMO_CONDITION_NAMES: Dict[int, str] = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Depositing rime fog", + 51: "Light drizzle", + 53: "Moderate drizzle", + 55: "Dense drizzle", + 56: "Light freezing drizzle", + 57: "Dense freezing drizzle", + 61: "Slight rain", + 63: "Moderate rain", + 65: "Heavy rain", + 66: "Light freezing rain", + 67: "Heavy freezing rain", + 71: "Slight snowfall", + 73: "Moderate snowfall", + 75: "Heavy snowfall", + 77: "Snow grains", + 80: "Slight rain showers", + 81: "Moderate rain showers", + 82: "Violent rain showers", + 85: "Slight snow showers", + 86: "Heavy snow showers", + 95: "Thunderstorm", + 96: "Thunderstorm with slight hail", + 99: "Thunderstorm with heavy hail", +} diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index df7c9d5..5210c4a 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -33,7 +33,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.processing.sync_clock_manager import SyncClockManager +from wled_controller.core.weather.weather_manager import WeatherManager from wled_controller.core.automations.automation_engine import AutomationEngine from wled_controller.core.mqtt.mqtt_service import MQTTService from wled_controller.core.devices.mqtt_client import set_mqtt_service @@ -72,7 +74,9 @@ sync_clock_store = SyncClockStore(config.storage.sync_clocks_file) cspt_store = ColorStripProcessingTemplateStore(config.storage.color_strip_processing_templates_file) gradient_store = GradientStore(config.storage.gradients_file) gradient_store.migrate_palette_references(color_strip_store) +weather_source_store = WeatherSourceStore(config.storage.weather_sources_file) sync_clock_manager = SyncClockManager(sync_clock_store) +weather_manager = WeatherManager(weather_source_store) processor_manager = ProcessorManager( ProcessorDependencies( @@ -88,6 +92,7 @@ processor_manager = ProcessorManager( sync_clock_manager=sync_clock_manager, cspt_store=cspt_store, gradient_store=gradient_store, + weather_manager=weather_manager, ) ) @@ -103,7 +108,7 @@ def _save_all_stores() -> None: picture_source_store, output_target_store, pattern_template_store, color_strip_store, audio_source_store, audio_template_store, value_source_store, automation_store, scene_preset_store, - sync_clock_store, cspt_store, gradient_store, + sync_clock_store, cspt_store, gradient_store, weather_source_store, ] saved = 0 for store in all_stores: @@ -195,6 +200,8 @@ async def lifespan(app: FastAPI): sync_clock_manager=sync_clock_manager, cspt_store=cspt_store, gradient_store=gradient_store, + weather_source_store=weather_source_store, + weather_manager=weather_manager, ) # Register devices in processor manager for health monitoring @@ -258,6 +265,12 @@ async def lifespan(app: FastAPI): # where no CRUD happened during the session. _save_all_stores() + # Stop weather manager + try: + weather_manager.shutdown() + except Exception as e: + logger.error(f"Error stopping weather manager: {e}") + # Stop auto-backup engine try: await auto_backup_engine.stop() diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index db655f2..a51f4df 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -1738,6 +1738,31 @@ background: var(--border-color); } +/* ── Weather source location row ── */ + +.weather-location-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.weather-location-field { + display: flex; + align-items: center; + gap: 4px; +} + +.weather-location-field label { + font-size: 0.85rem; + color: var(--text-secondary); + flex-shrink: 0; +} + +.weather-location-field input[type="number"] { + width: 80px; +} + .composite-layer-drag-handle:active { cursor: grabbing; } diff --git a/server/src/wled_controller/static/js/core/icons.ts b/server/src/wled_controller/static/js/core/icons.ts index 9342046..918413e 100644 --- a/server/src/wled_controller/static/js/core/icons.ts +++ b/server/src/wled_controller/static/js/core/icons.ts @@ -26,6 +26,7 @@ const _colorStripTypeIcons = { notification: _svg(P.bellRing), daylight: _svg(P.sun), candlelight: _svg(P.flame), + weather: _svg(P.cloudSun), processed: _svg(P.sparkles), }; const _valueSourceTypeIcons = { diff --git a/server/src/wled_controller/static/js/core/state.ts b/server/src/wled_controller/static/js/core/state.ts index 931d3c5..b309817 100644 --- a/server/src/wled_controller/static/js/core/state.ts +++ b/server/src/wled_controller/static/js/core/state.ts @@ -10,7 +10,7 @@ import { DataCache } from './cache.ts'; import type { Device, OutputTarget, ColorStripSource, PatternTemplate, ValueSource, AudioSource, PictureSource, ScenePreset, - SyncClock, Automation, Display, FilterDef, EngineInfo, + SyncClock, WeatherSource, Automation, Display, FilterDef, EngineInfo, CaptureTemplate, PostprocessingTemplate, AudioTemplate, ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle, } from '../types.ts'; @@ -225,6 +225,7 @@ export let _cachedValueSources: ValueSource[] = []; // Sync clocks export let _cachedSyncClocks: SyncClock[] = []; +export let _cachedWeatherSources: WeatherSource[] = []; // Automations export let _automationsCache: Automation[] | null = null; @@ -282,6 +283,12 @@ export const syncClocksCache = new DataCache({ }); syncClocksCache.subscribe(v => { _cachedSyncClocks = v; }); +export const weatherSourcesCache = new DataCache({ + endpoint: '/weather-sources', + extractData: json => json.sources || [], +}); +weatherSourcesCache.subscribe(v => { _cachedWeatherSources = v; }); + export const filtersCache = new DataCache({ endpoint: '/filters', extractData: json => json.filters || [], diff --git a/server/src/wled_controller/static/js/features/color-strips.ts b/server/src/wled_controller/static/js/features/color-strips.ts index 33af6ff..7893985 100644 --- a/server/src/wled_controller/static/js/features/color-strips.ts +++ b/server/src/wled_controller/static/js/features/color-strips.ts @@ -3,7 +3,7 @@ */ import { fetchWithAuth, escapeHtml } from '../core/api.ts'; -import { _cachedSyncClocks, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, GradientEntity } from '../core/state.ts'; +import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity } from '../core/state.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm, desktopFocus } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; @@ -127,7 +127,7 @@ let _processedTemplateEntitySelect: any = null; const CSS_TYPE_KEYS = [ 'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped', 'audio', - 'api_input', 'notification', 'daylight', 'candlelight', 'processed', + 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', ]; function _buildCSSTypeItems() { @@ -172,6 +172,7 @@ const CSS_SECTION_MAP: Record = { 'notification': 'css-editor-notification-section', 'daylight': 'css-editor-daylight-section', 'candlelight': 'css-editor-candlelight-section', + 'weather': 'css-editor-weather-section', 'processed': 'css-editor-processed-section', }; @@ -184,6 +185,7 @@ const CSS_TYPE_SETUP: Record void> = { gradient: () => { _ensureGradientPresetIconSelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); }, notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); }, candlelight: () => _ensureCandleTypeIconSelect(), + weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); }, composite: () => compositeRenderList(), mapped: () => _mappedRenderList(), }; @@ -238,7 +240,7 @@ export function onCSSTypeChange() { hasLedCount.includes(type) ? '' : 'none'; // Sync clock — shown for animated types - const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight']; + const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather']; (document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none'; if (clockTypes.includes(type)) _populateClockDropdown(); @@ -272,6 +274,29 @@ export function onCSSClockChange() { // No-op: speed sliders removed; speed is now clock-only } +let _weatherSourceEntitySelect: any = null; + +function _populateWeatherSourceDropdown() { + const sources = _cachedWeatherSources || []; + const sel = document.getElementById('css-editor-weather-source') as HTMLSelectElement; + const prev = sel.value; + sel.innerHTML = `` + + sources.map(s => ``).join(''); + sel.value = prev || ''; + if (_weatherSourceEntitySelect) _weatherSourceEntitySelect.destroy(); + if (sources.length > 0) { + _weatherSourceEntitySelect = new EntitySelect({ + target: sel, + getItems: () => (_cachedWeatherSources || []).map(s => ({ + value: s.id, + label: s.name, + icon: getColorStripIcon('weather'), + })), + placeholder: t('palette.search'), + }); + } +} + function _populateProcessedSelectors() { const editingId = (document.getElementById('css-editor-id') as HTMLInputElement).value; const allSources = (colorStripSourcesCache.data || []) as any[]; @@ -927,7 +952,7 @@ type CardPropsRenderer = (source: ColorStripSource, opts: { const NON_PICTURE_TYPES = new Set([ 'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped', - 'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'processed', + 'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', ]); const CSS_CARD_RENDERERS: Record = { @@ -1055,6 +1080,17 @@ const CSS_CARD_RENDERERS: Record = { ${clockBadge} `; }, + weather: (source, { clockBadge }) => { + const speedVal = (source.speed ?? 1.0).toFixed(1); + const tempInfl = (source.temperature_influence ?? 0.5).toFixed(1); + const wsName = (_cachedWeatherSources || []).find((w: any) => w.id === source.weather_source_id)?.name || '—'; + return ` + ${getColorStripIcon('weather')} ${escapeHtml(wsName)} + ⏩ ${speedVal}x + 🌡 ${tempInfl} + ${clockBadge} + `; + }, processed: (source) => { const inputSrc = ((colorStripSourcesCache.data || []) as any[]).find(s => s.id === source.input_source_id); const inputName = inputSrc?.name || source.input_source_id || '—'; @@ -1479,6 +1515,39 @@ const _typeHandlers: Record any; reset: (... }; }, }, + weather: { + async load(css) { + await weatherSourcesCache.fetch(); + _populateWeatherSourceDropdown(); + (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = css.weather_source_id || ''; + (document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = css.speed ?? 1.0; + (document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1); + (document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = css.temperature_influence ?? 0.5; + (document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = parseFloat(css.temperature_influence ?? 0.5).toFixed(2); + }, + async reset() { + await weatherSourcesCache.fetch(); + _populateWeatherSourceDropdown(); + (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = ''; + (document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = 1.0 as any; + (document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = '1.0'; + (document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = 0.5 as any; + (document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = '0.50'; + }, + getPayload(name) { + const wsId = (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value; + if (!wsId) { + cssEditorModal.showError(t('color_strip.weather.error.no_source')); + return null; + } + return { + name, + weather_source_id: wsId, + speed: parseFloat((document.getElementById('css-editor-weather-speed') as HTMLInputElement).value), + temperature_influence: parseFloat((document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value), + }; + }, + }, processed: { async load(css) { await csptCache.fetch(); @@ -1726,7 +1795,7 @@ export async function saveCSSEditor() { if (!cssId) payload.source_type = knownType ? sourceType : 'picture'; // Attach clock_id for animated types - const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight']; + const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather']; if (clockTypes.includes(sourceType)) { const clockVal = (document.getElementById('css-editor-clock') as HTMLInputElement).value; payload.clock_id = clockVal || null; diff --git a/server/src/wled_controller/static/js/features/streams.ts b/server/src/wled_controller/static/js/features/streams.ts index 5a910db..0c6d9a5 100644 --- a/server/src/wled_controller/static/js/features/streams.ts +++ b/server/src/wled_controller/static/js/features/streams.ts @@ -23,6 +23,7 @@ import { _cachedAudioSources, _cachedValueSources, _cachedSyncClocks, + _cachedWeatherSources, _cachedAudioTemplates, _cachedCSPTemplates, _csptModalFilters, set_csptModalFilters, @@ -34,7 +35,7 @@ import { _sourcesLoading, set_sourcesLoading, apiKey, streamsCache, ppTemplatesCache, captureTemplatesCache, - audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache, + audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, filtersCache, colorStripSourcesCache, csptCache, stripFiltersCache, gradientsCache, GradientEntity, @@ -49,6 +50,7 @@ import { TreeNav } from '../core/tree-nav.ts'; import { updateSubTabHash } from './tabs.ts'; import { createValueSourceCard } from './value-sources.ts'; import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts'; +import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts'; import { createColorStripCard } from './color-strips.ts'; import { initAudioSourceDelegation } from './audio-sources.ts'; import { @@ -94,6 +96,7 @@ const _audioTemplateDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', ic const _colorStripDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-sources', colorStripSourcesCache, 'color_strip.deleted') }]; const _valueSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('value-sources', valueSourcesCache, 'value_source.deleted') }]; const _syncClockDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('sync-clocks', syncClocksCache, 'sync_clock.deleted') }]; +const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }]; const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }]; // ── Card section instances ── @@ -109,6 +112,7 @@ const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_t const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id', emptyKey: 'section.empty.color_strips', bulkActions: _colorStripDeleteAction }); const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction }); const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction }); +const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction }); const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction }); const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }]; const csGradients = new CardSection('gradients', { titleKey: 'gradient.group.title', gridClass: 'templates-grid', addCardOnclick: "showGradientModal()", keyAttr: 'data-id', emptyKey: 'section.empty.gradients', bulkActions: _gradientDeleteAction }); @@ -220,6 +224,7 @@ export async function loadPictureSources() { audioSourcesCache.fetch(), valueSourcesCache.fetch(), syncClocksCache.fetch(), + weatherSourcesCache.fetch(), audioTemplatesCache.fetch(), colorStripSourcesCache.fetch(), csptCache.fetch(), @@ -274,6 +279,7 @@ const _streamSectionMap = { audio_templates: [csAudioTemplates], value: [csValueSources], sync: [csSyncClocks], + weather: [csWeatherSources], }; type StreamCardRenderer = (stream: any) => string; @@ -483,6 +489,7 @@ function renderPictureSourcesList(streams: any) { { key: 'audio_templates', icon: ICON_AUDIO_TEMPLATE, titleKey: 'streams.group.audio_templates', count: _cachedAudioTemplates.length }, { key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length }, { key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length }, + { key: 'weather', icon: `${P.cloudSun}`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length }, ]; // Build tree navigation structure @@ -533,6 +540,7 @@ function renderPictureSourcesList(streams: any) { children: [ { key: 'value', titleKey: 'streams.group.value', icon: ICON_VALUE_SOURCE, count: _cachedValueSources.length }, { key: 'sync', titleKey: 'streams.group.sync', icon: ICON_CLOCK, count: _cachedSyncClocks.length }, + { key: 'weather', titleKey: 'streams.group.weather', icon: `${P.cloudSun}`, count: _cachedWeatherSources.length }, ] } ]; @@ -663,6 +671,7 @@ function renderPictureSourcesList(streams: any) { const gradientItems = csGradients.applySortOrder(gradients.map(g => ({ key: g.id, html: renderGradientCard(g) }))); const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) }))); const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) }))); + const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) }))); const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) }))); if (csRawStreams.isMounted()) { @@ -681,6 +690,7 @@ function renderPictureSourcesList(streams: any) { audio_templates: _cachedAudioTemplates.length, value: _cachedValueSources.length, sync: _cachedSyncClocks.length, + weather: _cachedWeatherSources.length, }); csRawStreams.reconcile(rawStreamItems); csRawTemplates.reconcile(rawTemplateItems); @@ -696,6 +706,7 @@ function renderPictureSourcesList(streams: any) { csVideoStreams.reconcile(videoItems); csValueSources.reconcile(valueItems); csSyncClocks.reconcile(syncClockItems); + csWeatherSources.reconcile(weatherSourceItems); } else { // First render: build full HTML const panels = tabs.map(tab => { @@ -711,16 +722,18 @@ function renderPictureSourcesList(streams: any) { else if (tab.key === 'audio_templates') panelContent = csAudioTemplates.render(audioTemplateItems); else if (tab.key === 'value') panelContent = csValueSources.render(valueItems); else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems); + else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems); else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems); else panelContent = csStaticStreams.render(staticItems); return `
${panelContent}
`; }).join(''); container.innerHTML = panels; - CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks]); + CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources]); // Event delegation for card actions (replaces inline onclick handlers) initSyncClockDelegation(container); + initWeatherSourceDelegation(container); initAudioSourceDelegation(container); // Render tree sidebar with expand/collapse buttons @@ -738,6 +751,7 @@ function renderPictureSourcesList(streams: any) { 'audio-templates': 'audio_templates', 'value-sources': 'value', 'sync-clocks': 'sync', + 'weather-sources': 'weather', }); } } diff --git a/server/src/wled_controller/static/js/features/weather-sources.ts b/server/src/wled_controller/static/js/features/weather-sources.ts new file mode 100644 index 0000000..3e9299c --- /dev/null +++ b/server/src/wled_controller/static/js/features/weather-sources.ts @@ -0,0 +1,329 @@ +/** + * Weather Sources — CRUD, test, cards. + */ + +import { _cachedWeatherSources, weatherSourcesCache } from '../core/state.ts'; +import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { t } from '../core/i18n.ts'; +import { Modal } from '../core/modal.ts'; +import { showToast, showConfirm } from '../core/ui.ts'; +import { ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.ts'; +import * as P from '../core/icon-paths.ts'; +import { IconSelect } from '../core/icon-select.ts'; +import { wrapCard } from '../core/card-colors.ts'; +import { TagInput, renderTagChips } from '../core/tag-input.ts'; +import { loadPictureSources } from './streams.ts'; +import type { WeatherSource } from '../types.ts'; + +const ICON_WEATHER = `${P.cloudSun}`; +const _icon = (d: string) => `${d}`; + +function _getProviderItems() { + return [ + { value: 'open_meteo', icon: _icon(P.cloudSun), label: 'Open-Meteo', desc: t('weather_source.provider.open_meteo.desc') }, + ]; +} + +// ── Modal ── + +let _weatherSourceTagsInput: TagInput | null = null; +let _providerIconSelect: IconSelect | null = null; + +class WeatherSourceModal extends Modal { + constructor() { super('weather-source-modal'); } + + onForceClose() { + if (_weatherSourceTagsInput) { _weatherSourceTagsInput.destroy(); _weatherSourceTagsInput = null; } + if (_providerIconSelect) { _providerIconSelect.destroy(); _providerIconSelect = null; } + } + + snapshotValues() { + return { + name: (document.getElementById('weather-source-name') as HTMLInputElement).value, + provider: (document.getElementById('weather-source-provider') as HTMLSelectElement).value, + latitude: (document.getElementById('weather-source-latitude') as HTMLInputElement).value, + longitude: (document.getElementById('weather-source-longitude') as HTMLInputElement).value, + interval: (document.getElementById('weather-source-interval') as HTMLInputElement).value, + description: (document.getElementById('weather-source-description') as HTMLInputElement).value, + tags: JSON.stringify(_weatherSourceTagsInput ? _weatherSourceTagsInput.getValue() : []), + }; + } +} + +const weatherSourceModal = new WeatherSourceModal(); + +// ── Show / Close ── + +export async function showWeatherSourceModal(editData: WeatherSource | null = null): Promise { + const isEdit = !!editData; + const titleKey = isEdit ? 'weather_source.edit' : 'weather_source.add'; + document.getElementById('weather-source-modal-title')!.innerHTML = `${ICON_WEATHER} ${t(titleKey)}`; + (document.getElementById('weather-source-id') as HTMLInputElement).value = editData?.id || ''; + (document.getElementById('weather-source-error') as HTMLElement).style.display = 'none'; + + if (isEdit) { + (document.getElementById('weather-source-name') as HTMLInputElement).value = editData.name || ''; + (document.getElementById('weather-source-provider') as HTMLSelectElement).value = editData.provider || 'open_meteo'; + (document.getElementById('weather-source-latitude') as HTMLInputElement).value = String(editData.latitude ?? 50.0); + (document.getElementById('weather-source-longitude') as HTMLInputElement).value = String(editData.longitude ?? 0.0); + (document.getElementById('weather-source-interval') as HTMLInputElement).value = String(editData.update_interval ?? 600); + document.getElementById('weather-source-interval-display')!.textContent = String(Math.round((editData.update_interval ?? 600) / 60)); + (document.getElementById('weather-source-description') as HTMLInputElement).value = editData.description || ''; + } else { + (document.getElementById('weather-source-name') as HTMLInputElement).value = ''; + (document.getElementById('weather-source-provider') as HTMLSelectElement).value = 'open_meteo'; + (document.getElementById('weather-source-latitude') as HTMLInputElement).value = '50.0'; + (document.getElementById('weather-source-longitude') as HTMLInputElement).value = '0.0'; + (document.getElementById('weather-source-interval') as HTMLInputElement).value = '600'; + document.getElementById('weather-source-interval-display')!.textContent = '10'; + (document.getElementById('weather-source-description') as HTMLInputElement).value = ''; + } + + // Tags + if (_weatherSourceTagsInput) { _weatherSourceTagsInput.destroy(); _weatherSourceTagsInput = null; } + _weatherSourceTagsInput = new TagInput(document.getElementById('weather-source-tags-container'), { placeholder: t('tags.placeholder') }); + _weatherSourceTagsInput.setValue(isEdit ? (editData.tags || []) : []); + + // Provider IconSelect + if (_providerIconSelect) { _providerIconSelect.destroy(); _providerIconSelect = null; } + _providerIconSelect = new IconSelect({ + target: document.getElementById('weather-source-provider') as HTMLSelectElement, + items: _getProviderItems(), + columns: 1, + }); + + // Show/hide test button based on edit mode + const testBtn = document.getElementById('weather-source-test-btn'); + if (testBtn) testBtn.style.display = isEdit ? '' : 'none'; + + weatherSourceModal.open(); + weatherSourceModal.snapshot(); +} + +export async function closeWeatherSourceModal(): Promise { + await weatherSourceModal.close(); +} + +// ── Save ── + +export async function saveWeatherSource(): Promise { + const id = (document.getElementById('weather-source-id') as HTMLInputElement).value; + const name = (document.getElementById('weather-source-name') as HTMLInputElement).value.trim(); + const provider = (document.getElementById('weather-source-provider') as HTMLSelectElement).value; + const latitude = parseFloat((document.getElementById('weather-source-latitude') as HTMLInputElement).value) || 50.0; + const longitude = parseFloat((document.getElementById('weather-source-longitude') as HTMLInputElement).value) || 0.0; + const update_interval = parseInt((document.getElementById('weather-source-interval') as HTMLInputElement).value) || 600; + const description = (document.getElementById('weather-source-description') as HTMLInputElement).value.trim() || null; + + if (!name) { + weatherSourceModal.showError(t('weather_source.error.name_required')); + return; + } + + const payload = { + name, provider, latitude, longitude, update_interval, description, + tags: _weatherSourceTagsInput ? _weatherSourceTagsInput.getValue() : [], + }; + + try { + const method = id ? 'PUT' : 'POST'; + const url = id ? `/weather-sources/${id}` : '/weather-sources'; + const resp = await fetchWithAuth(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + showToast(t(id ? 'weather_source.updated' : 'weather_source.created'), 'success'); + weatherSourceModal.forceClose(); + weatherSourcesCache.invalidate(); + await loadPictureSources(); + } catch (e: any) { + if (e.isAuth) return; + weatherSourceModal.showError(e.message); + } +} + +// ── Edit / Clone / Delete ── + +export async function editWeatherSource(sourceId: string): Promise { + try { + const resp = await fetchWithAuth(`/weather-sources/${sourceId}`); + if (!resp.ok) throw new Error(t('weather_source.error.load')); + const data = await resp.json(); + await showWeatherSourceModal(data); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +export async function cloneWeatherSource(sourceId: string): Promise { + try { + const resp = await fetchWithAuth(`/weather-sources/${sourceId}`); + if (!resp.ok) throw new Error(t('weather_source.error.load')); + const data = await resp.json(); + delete data.id; + data.name = data.name + ' (copy)'; + await showWeatherSourceModal(data); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +export async function deleteWeatherSource(sourceId: string): Promise { + const confirmed = await showConfirm(t('weather_source.delete.confirm')); + if (!confirmed) return; + try { + const resp = await fetchWithAuth(`/weather-sources/${sourceId}`, { method: 'DELETE' }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + showToast(t('weather_source.deleted'), 'success'); + weatherSourcesCache.invalidate(); + await loadPictureSources(); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +// ── Test (fetch current weather) ── + +export async function testWeatherSource(): Promise { + const id = (document.getElementById('weather-source-id') as HTMLInputElement).value; + if (!id) return; + + const testBtn = document.getElementById('weather-source-test-btn'); + if (testBtn) testBtn.classList.add('loading'); + + try { + const resp = await fetchWithAuth(`/weather-sources/${id}/test`, { method: 'POST' }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success'); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } finally { + if (testBtn) testBtn.classList.remove('loading'); + } +} + +// ── Geolocation ── + +export function weatherSourceGeolocate(): void { + const btn = document.getElementById('weather-source-geolocate-btn'); + if (!navigator.geolocation) { + showToast(t('weather_source.geo.not_supported'), 'error'); + return; + } + + if (btn) btn.classList.add('loading'); + + navigator.geolocation.getCurrentPosition( + (pos) => { + (document.getElementById('weather-source-latitude') as HTMLInputElement).value = pos.coords.latitude.toFixed(4); + (document.getElementById('weather-source-longitude') as HTMLInputElement).value = pos.coords.longitude.toFixed(4); + if (btn) btn.classList.remove('loading'); + showToast(t('weather_source.geo.success'), 'success'); + }, + (err) => { + if (btn) btn.classList.remove('loading'); + showToast(`${t('weather_source.geo.error')}: ${err.message}`, 'error'); + }, + { timeout: 10000, maximumAge: 60000 } + ); +} + +// ── Card rendering ── + +export function createWeatherSourceCard(source: WeatherSource) { + const intervalMin = Math.round(source.update_interval / 60); + const providerLabel = source.provider === 'open_meteo' ? 'Open-Meteo' : source.provider; + + return wrapCard({ + type: 'template-card', + dataAttr: 'data-id', + id: source.id, + removeOnclick: `deleteWeatherSource('${source.id}')`, + removeTitle: t('common.delete'), + content: ` +
+
${ICON_WEATHER} ${escapeHtml(source.name)}
+
+
+ ${ICON_WEATHER} ${providerLabel} + + ${P.mapPin} ${source.latitude.toFixed(1)}, ${source.longitude.toFixed(1)} + + + ${P.clock} ${intervalMin}min + +
+ ${renderTagChips(source.tags)} + ${source.description ? `
${escapeHtml(source.description)}
` : ''}`, + actions: ` + + + `, + }); +} + +// ── Event delegation ── + +const _weatherSourceActions: Record void> = { + test: (id) => _testWeatherSourceFromCard(id), + clone: cloneWeatherSource, + edit: editWeatherSource, +}; + +async function _testWeatherSourceFromCard(sourceId: string): Promise { + try { + const resp = await fetchWithAuth(`/weather-sources/${sourceId}/test`, { method: 'POST' }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success'); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +export function initWeatherSourceDelegation(container: HTMLElement): void { + container.addEventListener('click', (e: MouseEvent) => { + const btn = (e.target as HTMLElement).closest('[data-action]'); + if (!btn) return; + + const section = btn.closest('[data-card-section="weather-sources"]'); + if (!section) return; + const card = btn.closest('[data-id]'); + if (!card) return; + + const action = btn.dataset.action; + const id = card.getAttribute('data-id'); + if (!action || !id) return; + + const handler = _weatherSourceActions[action]; + if (handler) { + e.stopPropagation(); + handler(id); + } + }); +} + +// ── Expose to global scope for HTML template onclick handlers ── + +window.showWeatherSourceModal = showWeatherSourceModal; +window.closeWeatherSourceModal = closeWeatherSourceModal; +window.saveWeatherSource = saveWeatherSource; +window.editWeatherSource = editWeatherSource; +window.cloneWeatherSource = cloneWeatherSource; +window.deleteWeatherSource = deleteWeatherSource; +window.testWeatherSource = testWeatherSource; +window.weatherSourceGeolocate = weatherSourceGeolocate; diff --git a/server/src/wled_controller/static/js/types.ts b/server/src/wled_controller/static/js/types.ts index a1f7bca..229a8e0 100644 --- a/server/src/wled_controller/static/js/types.ts +++ b/server/src/wled_controller/static/js/types.ts @@ -223,6 +223,10 @@ export interface ColorStripSource { // Processed input_source_id?: string; processing_template_id?: string; + + // Weather + weather_source_id?: string; + temperature_influence?: number; } // ── Pattern Template ────────────────────────────────────────── @@ -384,6 +388,25 @@ export interface SyncClock { updated_at: string; } +export interface WeatherSource { + id: string; + name: string; + provider: string; + provider_config: Record; + latitude: number; + longitude: number; + update_interval: number; + description?: string; + tags: string[]; + created_at: string; + updated_at: string; +} + +export interface WeatherSourceListResponse { + sources: WeatherSource[]; + count: number; +} + // ── Automation ──────────────────────────────────────────────── export type ConditionType = diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 37bb3c0..3bba6a1 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1140,6 +1140,16 @@ "color_strip.type.candlelight": "Candlelight", "color_strip.type.candlelight.desc": "Realistic flickering candle simulation", "color_strip.type.candlelight.hint": "Simulates realistic candle flickering across all LEDs with warm tones and organic flicker patterns.", + "color_strip.type.weather": "Weather", + "color_strip.type.weather.desc": "Weather-reactive ambient colors", + "color_strip.type.weather.hint": "Maps real-time weather conditions to ambient LED colors. Requires a Weather Source entity.", + "color_strip.weather.source": "Weather Source:", + "color_strip.weather.source.hint": "The weather data source to use for ambient colors. Create one in the Weather tab first.", + "color_strip.weather.speed": "Animation Speed:", + "color_strip.weather.speed.hint": "Speed of the ambient color drift animation. Higher = faster movement.", + "color_strip.weather.temperature_influence": "Temperature Influence:", + "color_strip.weather.temperature_influence.hint": "How much the current temperature shifts the palette warm/cool. 0 = pure condition colors, 1 = strong shift.", + "color_strip.weather.error.no_source": "Please select a weather source", "color_strip.candlelight.color": "Base Color:", "color_strip.candlelight.color.hint": "The warm base color of the candle flame. Default is a natural warm amber.", "color_strip.candlelight.intensity": "Flicker Intensity:", @@ -1704,6 +1714,35 @@ "sync_clock.reset_done": "Clock reset to zero", "sync_clock.delete.confirm": "Delete this sync clock? Linked sources will lose synchronization and run at default speed.", "sync_clock.elapsed": "Elapsed time", + "weather_source.group.title": "Weather Sources", + "weather_source.add": "Add Weather Source", + "weather_source.edit": "Edit Weather Source", + "weather_source.name": "Name:", + "weather_source.name.placeholder": "My Weather", + "weather_source.name.hint": "A descriptive name for this weather data source", + "weather_source.provider": "Provider:", + "weather_source.provider.hint": "Weather data provider. Open-Meteo is free and requires no API key.", + "weather_source.provider.open_meteo.desc": "Free, no API key required", + "weather_source.location": "Location:", + "weather_source.location.hint": "Your geographic coordinates. Use the auto-detect button or enter manually.", + "weather_source.latitude": "Lat:", + "weather_source.longitude": "Lon:", + "weather_source.use_my_location": "Use my location", + "weather_source.update_interval": "Update Interval:", + "weather_source.update_interval.hint": "How often to fetch weather data. Lower values give more responsive changes.", + "weather_source.description": "Description (optional):", + "weather_source.description.placeholder": "Optional description", + "weather_source.test": "Test", + "weather_source.error.name_required": "Weather source name is required", + "weather_source.error.load": "Failed to load weather source", + "weather_source.created": "Weather source created", + "weather_source.updated": "Weather source updated", + "weather_source.deleted": "Weather source deleted", + "weather_source.delete.confirm": "Delete this weather source? Linked color strip sources will lose weather data.", + "weather_source.geo.success": "Location detected", + "weather_source.geo.error": "Geolocation failed", + "weather_source.geo.not_supported": "Geolocation is not supported by your browser", + "streams.group.weather": "Weather", "color_strip.clock": "Sync Clock:", "color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.", "graph.title": "Graph", @@ -1814,6 +1853,7 @@ "section.empty.color_strips": "No color strips yet. Click + to add one.", "section.empty.value_sources": "No value sources yet. Click + to add one.", "section.empty.sync_clocks": "No sync clocks yet. Click + to add one.", + "section.empty.weather_sources": "No weather sources yet. Click + to add one.", "section.empty.cspt": "No CSS processing templates yet. Click + to add one.", "section.empty.automations": "No automations yet. Click + to add one.", "section.empty.scenes": "No scene presets yet. Click + to add one.", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 8770009..b9d9700 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1117,6 +1117,16 @@ "color_strip.type.candlelight": "Свечи", "color_strip.type.candlelight.desc": "Реалистичная имитация мерцания свечей", "color_strip.type.candlelight.hint": "Реалистичное мерцание свечей с тёплыми тонами и органическими паттернами.", + "color_strip.type.weather": "Погода", + "color_strip.type.weather.desc": "Погодно-реактивные амбиентные цвета", + "color_strip.type.weather.hint": "Отображает текущие погодные условия в амбиентные цвета LED. Требуется сущность Источник погоды.", + "color_strip.weather.source": "Источник погоды:", + "color_strip.weather.source.hint": "Источник метеоданных. Сначала создайте его во вкладке Погода.", + "color_strip.weather.speed": "Скорость анимации:", + "color_strip.weather.speed.hint": "Скорость дрейфа цветов. Выше = быстрее.", + "color_strip.weather.temperature_influence": "Влияние температуры:", + "color_strip.weather.temperature_influence.hint": "Насколько температура смещает палитру в тёплую/холодную сторону. 0 = чистые цвета условий, 1 = сильное смещение.", + "color_strip.weather.error.no_source": "Выберите источник погоды", "color_strip.candlelight.color": "Базовый цвет:", "color_strip.candlelight.color.hint": "Тёплый базовый цвет пламени свечи. По умолчанию — натуральный тёплый янтарь.", "color_strip.candlelight.intensity": "Интенсивность мерцания:", @@ -1631,6 +1641,36 @@ "sync_clock.reset_done": "Часы сброшены на ноль", "sync_clock.delete.confirm": "Удалить эти часы синхронизации? Привязанные источники потеряют синхронизацию и будут работать на скорости по умолчанию.", "sync_clock.elapsed": "Прошло времени", + "weather_source.group.title": "Источники погоды", + "weather_source.add": "Добавить источник погоды", + "weather_source.edit": "Редактировать источник погоды", + "weather_source.name": "Название:", + "weather_source.name.placeholder": "Моя погода", + "weather_source.name.hint": "Описательное название источника погоды", + "weather_source.provider": "Провайдер:", + "weather_source.provider.hint": "Провайдер метеоданных. Open-Meteo бесплатный и не требует API ключа.", + "weather_source.provider.open_meteo.desc": "Бесплатно, без API ключа", + "weather_source.location": "Местоположение:", + "weather_source.location.hint": "Географические координаты. Используйте автоопределение или введите вручную.", + "weather_source.latitude": "Шир:", + "weather_source.longitude": "Долг:", + "weather_source.use_my_location": "Определить", + "weather_source.update_interval": "Интервал обновления:", + "weather_source.update_interval.hint": "Частота запроса метеоданных. Меньше = более оперативные обновления.", + "weather_source.description": "Описание (необязательно):", + "weather_source.description.placeholder": "Необязательное описание", + "weather_source.test": "Тест", + "weather_source.error.name_required": "Название источника погоды обязательно", + "weather_source.error.load": "Не удалось загрузить источник погоды", + "weather_source.created": "Источник погоды создан", + "weather_source.updated": "Источник погоды обновлён", + "weather_source.deleted": "Источник погоды удалён", + "weather_source.delete.confirm": "Удалить этот источник погоды? Связанные источники цветовых лент потеряют данные о погоде.", + "weather_source.geo.success": "Местоположение определено", + "weather_source.geo.error": "Ошибка геолокации", + "weather_source.geo.not_supported": "Геолокация не поддерживается вашим браузером", + "streams.group.weather": "Погода", + "section.empty.weather_sources": "Нет источников погоды. Нажмите + для добавления.", "color_strip.clock": "Часы синхронизации:", "color_strip.clock.hint": "Привязка к часам для синхронизации анимации между источниками. Скорость управляется на часах.", "graph.title": "Граф", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 01da593..e45dc24 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -1117,6 +1117,16 @@ "color_strip.type.candlelight": "烛光", "color_strip.type.candlelight.desc": "逼真的烛光闪烁模拟", "color_strip.type.candlelight.hint": "在所有LED上模拟逼真的蜡烛闪烁,具有温暖色调和有机闪烁模式。", + "color_strip.type.weather": "天气", + "color_strip.type.weather.desc": "天气感应环境色彩", + "color_strip.type.weather.hint": "将实时天气状况映射为环境LED颜色。需要天气源实体。", + "color_strip.weather.source": "天气源:", + "color_strip.weather.source.hint": "用于环境颜色的天气数据源。请先在天气标签页中创建。", + "color_strip.weather.speed": "动画速度:", + "color_strip.weather.speed.hint": "环境色彩漂移动画速度。越高越快。", + "color_strip.weather.temperature_influence": "温度影响:", + "color_strip.weather.temperature_influence.hint": "当前温度对调色板冷暖偏移程度。0=纯天气颜色,1=强偏移。", + "color_strip.weather.error.no_source": "请选择天气源", "color_strip.candlelight.color": "基础颜色:", "color_strip.candlelight.color.hint": "蜡烛火焰的温暖基础颜色。默认为自然温暖的琥珀色。", "color_strip.candlelight.intensity": "闪烁强度:", @@ -1631,6 +1641,36 @@ "sync_clock.reset_done": "时钟已重置为零", "sync_clock.delete.confirm": "删除此同步时钟?关联的源将失去同步并以默认速度运行。", "sync_clock.elapsed": "已用时间", + "weather_source.group.title": "天气源", + "weather_source.add": "添加天气源", + "weather_source.edit": "编辑天气源", + "weather_source.name": "名称:", + "weather_source.name.placeholder": "我的天气", + "weather_source.name.hint": "天气数据源的描述性名称", + "weather_source.provider": "提供商:", + "weather_source.provider.hint": "天气数据提供商。Open-Meteo免费且无需API密钥。", + "weather_source.provider.open_meteo.desc": "免费,无需API密钥", + "weather_source.location": "位置:", + "weather_source.location.hint": "您的地理坐标。使用自动检测按钮或手动输入。", + "weather_source.latitude": "纬度:", + "weather_source.longitude": "经度:", + "weather_source.use_my_location": "使用我的位置", + "weather_source.update_interval": "更新间隔:", + "weather_source.update_interval.hint": "获取天气数据的频率。值越低,更新越及时。", + "weather_source.description": "描述(可选):", + "weather_source.description.placeholder": "可选描述", + "weather_source.test": "测试", + "weather_source.error.name_required": "天气源名称为必填项", + "weather_source.error.load": "加载天气源失败", + "weather_source.created": "天气源已创建", + "weather_source.updated": "天气源已更新", + "weather_source.deleted": "天气源已删除", + "weather_source.delete.confirm": "删除此天气源?关联的色带源将失去天气数据。", + "weather_source.geo.success": "位置已检测", + "weather_source.geo.error": "地理定位失败", + "weather_source.geo.not_supported": "您的浏览器不支持地理定位", + "streams.group.weather": "天气", + "section.empty.weather_sources": "暂无天气源。点击+添加。", "color_strip.clock": "同步时钟:", "color_strip.clock.hint": "关联同步时钟以在多个源之间同步动画。速度在时钟上控制。", "graph.title": "图表", diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 8ad8c1b..04c662a 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -1084,6 +1084,60 @@ class ProcessedColorStripSource(ColorStripSource): self.processing_template_id = resolve_ref(processing_template_id, self.processing_template_id) +@dataclass +class WeatherColorStripSource(ColorStripSource): + """Color strip source driven by real-time weather data. + + References a WeatherSource entity for API polling. Maps weather + conditions (rain, snow, clear, storm) to ambient color palettes + with temperature-influenced hue shifting. + """ + + weather_source_id: str = "" # reference to WeatherSource entity + speed: float = 1.0 # ambient drift animation speed + temperature_influence: float = 0.5 # 0.0=none, 1.0=full temp hue shift + + def to_dict(self) -> dict: + d = super().to_dict() + d["weather_source_id"] = self.weather_source_id + d["speed"] = self.speed + d["temperature_influence"] = self.temperature_influence + return d + + @classmethod + def from_dict(cls, data: dict) -> "WeatherColorStripSource": + common = _parse_css_common(data) + return cls( + **common, source_type="weather", + weather_source_id=data.get("weather_source_id", ""), + speed=float(data.get("speed") or 1.0), + temperature_influence=float(data.get("temperature_influence") if data.get("temperature_influence") is not None else 0.5), + ) + + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description=None, clock_id=None, tags=None, + weather_source_id=None, speed=None, + temperature_influence=None, **_kwargs): + return cls( + id=id, name=name, source_type="weather", + created_at=created_at, updated_at=updated_at, + description=description, clock_id=clock_id, tags=tags or [], + weather_source_id=weather_source_id or "", + speed=float(speed) if speed is not None else 1.0, + temperature_influence=float(temperature_influence) if temperature_influence is not None else 0.5, + ) + + def apply_update(self, **kwargs) -> None: + if kwargs.get("weather_source_id") is not None: + self.weather_source_id = resolve_ref(kwargs["weather_source_id"], self.weather_source_id) + if kwargs.get("speed") is not None: + self.speed = float(kwargs["speed"]) + if kwargs.get("temperature_influence") is not None: + self.temperature_influence = float(kwargs["temperature_influence"]) + + # -- Source type registry -- # Maps source_type string to its subclass for factory dispatch. _SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = { @@ -1101,4 +1155,5 @@ _SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = { "daylight": DaylightColorStripSource, "candlelight": CandlelightColorStripSource, "processed": ProcessedColorStripSource, + "weather": WeatherColorStripSource, } diff --git a/server/src/wled_controller/storage/weather_source.py b/server/src/wled_controller/storage/weather_source.py new file mode 100644 index 0000000..4530c2e --- /dev/null +++ b/server/src/wled_controller/storage/weather_source.py @@ -0,0 +1,67 @@ +"""Weather source data model. + +A WeatherSource represents a weather data feed — a specific provider + location. +It is a standalone entity referenced by WeatherColorStripSource via weather_source_id. +""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Dict, List, Optional + + +def _parse_common(data: dict) -> dict: + """Extract common fields from a dict, parsing timestamps.""" + created = data.get("created_at", "") + updated = data.get("updated_at", "") + return { + "id": data["id"], + "name": data["name"], + "created_at": datetime.fromisoformat(created) if isinstance(created, str) and created else datetime.now(timezone.utc), + "updated_at": datetime.fromisoformat(updated) if isinstance(updated, str) and updated else datetime.now(timezone.utc), + "description": data.get("description"), + "tags": data.get("tags") or [], + } + + +@dataclass +class WeatherSource: + """Weather data source configuration.""" + + id: str + name: str + created_at: datetime + updated_at: datetime + provider: str = "open_meteo" + provider_config: Dict = field(default_factory=dict) + latitude: float = 50.0 + longitude: float = 0.0 + update_interval: int = 600 # seconds (10 min default) + description: Optional[str] = None + tags: List[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "provider": self.provider, + "provider_config": dict(self.provider_config), + "latitude": self.latitude, + "longitude": self.longitude, + "update_interval": self.update_interval, + "description": self.description, + "tags": self.tags, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } + + @staticmethod + def from_dict(data: dict) -> "WeatherSource": + common = _parse_common(data) + return WeatherSource( + **common, + provider=data.get("provider", "open_meteo"), + provider_config=data.get("provider_config") or {}, + latitude=data.get("latitude", 50.0), + longitude=data.get("longitude", 0.0), + update_interval=data.get("update_interval", 600), + ) diff --git a/server/src/wled_controller/storage/weather_source_store.py b/server/src/wled_controller/storage/weather_source_store.py new file mode 100644 index 0000000..c91bccd --- /dev/null +++ b/server/src/wled_controller/storage/weather_source_store.py @@ -0,0 +1,120 @@ +"""Weather source storage using JSON files.""" + +import uuid +from datetime import datetime, timezone +from typing import List, Optional + +from wled_controller.storage.base_store import BaseJsonStore +from wled_controller.storage.weather_source import WeatherSource +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class WeatherSourceStore(BaseJsonStore[WeatherSource]): + """Persistent storage for weather sources.""" + + _json_key = "weather_sources" + _entity_name = "Weather source" + + def __init__(self, file_path: str): + super().__init__(file_path, WeatherSource.from_dict) + + # Backward-compatible aliases + get_all_sources = BaseJsonStore.get_all + get_source = BaseJsonStore.get + delete_source = BaseJsonStore.delete + + def create_source( + self, + name: str, + provider: str = "open_meteo", + provider_config: Optional[dict] = None, + latitude: float = 50.0, + longitude: float = 0.0, + update_interval: int = 600, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> WeatherSource: + from wled_controller.core.weather.weather_provider import PROVIDER_REGISTRY + + if provider not in PROVIDER_REGISTRY: + raise ValueError(f"Unknown weather provider: {provider}") + if not (60 <= update_interval <= 3600): + raise ValueError("update_interval must be between 60 and 3600 seconds") + if not (-90.0 <= latitude <= 90.0): + raise ValueError("latitude must be between -90 and 90") + if not (-180.0 <= longitude <= 180.0): + raise ValueError("longitude must be between -180 and 180") + + self._check_name_unique(name) + + sid = f"ws_{uuid.uuid4().hex[:8]}" + now = datetime.now(timezone.utc) + + source = WeatherSource( + id=sid, + name=name, + created_at=now, + updated_at=now, + provider=provider, + provider_config=provider_config or {}, + latitude=latitude, + longitude=longitude, + update_interval=update_interval, + description=description, + tags=tags or [], + ) + + self._items[sid] = source + self._save() + logger.info(f"Created weather source: {name} ({sid})") + return source + + def update_source( + self, + source_id: str, + name: Optional[str] = None, + provider: Optional[str] = None, + provider_config: Optional[dict] = None, + latitude: Optional[float] = None, + longitude: Optional[float] = None, + update_interval: Optional[int] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> WeatherSource: + existing = self.get(source_id) + + if name is not None and name != existing.name: + self._check_name_unique(name) + + if provider is not None: + from wled_controller.core.weather.weather_provider import PROVIDER_REGISTRY + if provider not in PROVIDER_REGISTRY: + raise ValueError(f"Unknown weather provider: {provider}") + + if update_interval is not None and not (60 <= update_interval <= 3600): + raise ValueError("update_interval must be between 60 and 3600 seconds") + if latitude is not None and not (-90.0 <= latitude <= 90.0): + raise ValueError("latitude must be between -90 and 90") + if longitude is not None and not (-180.0 <= longitude <= 180.0): + raise ValueError("longitude must be between -180 and 180") + + updated = WeatherSource( + id=existing.id, + name=name if name is not None else existing.name, + created_at=existing.created_at, + updated_at=datetime.now(timezone.utc), + provider=provider if provider is not None else existing.provider, + provider_config=provider_config if provider_config is not None else existing.provider_config, + latitude=latitude if latitude is not None else existing.latitude, + longitude=longitude if longitude is not None else existing.longitude, + update_interval=update_interval if update_interval is not None else existing.update_interval, + description=description if description is not None else existing.description, + tags=tags if tags is not None else existing.tags, + ) + + self._items[source_id] = updated + self._save() + logger.info(f"Updated weather source: {updated.name} ({source_id})") + return updated diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index 9f935b8..5c8fb60 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -210,6 +210,7 @@ {% include 'modals/value-source-editor.html' %} {% include 'modals/test-value-source.html' %} {% include 'modals/sync-clock-editor.html' %} + {% include 'modals/weather-source-editor.html' %} {% include 'modals/settings.html' %} {% include 'partials/tutorial-overlay.html' %} diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index 994157b..885f5fa 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -35,6 +35,7 @@ + @@ -566,6 +567,38 @@ + + +