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

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

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

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

i18n for en/ru/zh.
This commit is contained in:
2026-03-24 18:52:46 +03:00
parent 0723c5c68c
commit ef33935188
31 changed files with 1868 additions and 11 deletions

47
TODO.md Normal file
View File

@@ -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

View File

@@ -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"]

View File

@@ -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,
})

View File

@@ -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]

View File

@@ -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,
)

View File

@@ -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")

View File

@@ -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)")

View File

@@ -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):

View File

@@ -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:

View File

@@ -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,

View File

@@ -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

View File

@@ -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")

View File

@@ -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",
}

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -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 = {

View File

@@ -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<SyncClock[]>({
});
syncClocksCache.subscribe(v => { _cachedSyncClocks = v; });
export const weatherSourcesCache = new DataCache<WeatherSource[]>({
endpoint: '/weather-sources',
extractData: json => json.sources || [],
});
weatherSourcesCache.subscribe(v => { _cachedWeatherSources = v; });
export const filtersCache = new DataCache<FilterDef[]>({
endpoint: '/filters',
extractData: json => json.filters || [],

View File

@@ -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<string, string> = {
'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<string, () => 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 = `<option value="">\u2014</option>` +
sources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).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<string, CardPropsRenderer> = {
@@ -1055,6 +1080,17 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
${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 `
<span class="stream-card-prop">${getColorStripIcon('weather')} ${escapeHtml(wsName)}</span>
<span class="stream-card-prop">⏩ ${speedVal}x</span>
<span class="stream-card-prop">🌡 ${tempInfl}</span>
${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<string, { load: (...args: any[]) => 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;

View File

@@ -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: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, 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: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, 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 `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).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',
});
}
}

View File

@@ -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 = `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`;
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
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<void> {
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<void> {
await weatherSourceModal.close();
}
// ── Save ──
export async function saveWeatherSource(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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: `
<div class="template-card-header">
<div class="template-name">${ICON_WEATHER} ${escapeHtml(source.name)}</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">${ICON_WEATHER} ${providerLabel}</span>
<span class="stream-card-prop" title="${source.latitude.toFixed(2)}, ${source.longitude.toFixed(2)}">
<svg class="icon" viewBox="0 0 24 24">${P.mapPin}</svg> ${source.latitude.toFixed(1)}, ${source.longitude.toFixed(1)}
</span>
<span class="stream-card-prop">
<svg class="icon" viewBox="0 0 24 24">${P.clock}</svg> ${intervalMin}min
</span>
</div>
${renderTagChips(source.tags)}
${source.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(source.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" data-action="test" title="${t('weather_source.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" data-action="clone" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── Event delegation ──
const _weatherSourceActions: Record<string, (id: string) => void> = {
test: (id) => _testWeatherSourceFromCard(id),
clone: cloneWeatherSource,
edit: editWeatherSource,
};
async function _testWeatherSourceFromCard(sourceId: string): Promise<void> {
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<HTMLElement>('[data-action]');
if (!btn) return;
const section = btn.closest<HTMLElement>('[data-card-section="weather-sources"]');
if (!section) return;
const card = btn.closest<HTMLElement>('[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;

View File

@@ -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<string, any>;
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 =

View File

@@ -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.",

View File

@@ -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": "Граф",

View File

@@ -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": "图表",

View File

@@ -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,
}

View File

@@ -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),
)

View File

@@ -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

View File

@@ -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' %}

View File

@@ -35,6 +35,7 @@
<option value="notification" data-i18n="color_strip.type.notification">Notification</option>
<option value="daylight" data-i18n="color_strip.type.daylight">Daylight Cycle</option>
<option value="candlelight" data-i18n="color_strip.type.candlelight">Candlelight</option>
<option value="weather" data-i18n="color_strip.type.weather">Weather</option>
<option value="processed" data-i18n="color_strip.type.processed">Processed</option>
</select>
</div>
@@ -566,6 +567,38 @@
</div>
<!-- Processed type fields -->
<!-- Weather-specific fields -->
<div id="css-editor-weather-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="css-editor-weather-source" data-i18n="color_strip.weather.source">Weather Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.weather.source.hint">The weather data source to use for ambient colors</small>
<select id="css-editor-weather-source">
<option value=""></option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-weather-speed"><span data-i18n="color_strip.weather.speed">Animation Speed:</span> <span id="css-editor-weather-speed-val">1.0</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.weather.speed.hint">Speed of the ambient color drift animation</small>
<input type="range" id="css-editor-weather-speed" min="0.1" max="5" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-weather-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-weather-temp-influence"><span data-i18n="color_strip.weather.temperature_influence">Temperature Influence:</span> <span id="css-editor-weather-temp-val">0.5</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.weather.temperature_influence.hint">How much the current temperature shifts the color palette warm/cool. 0 = pure condition colors, 1 = strong shift.</small>
<input type="range" id="css-editor-weather-temp-influence" min="0" max="1" step="0.05" value="0.5"
oninput="document.getElementById('css-editor-weather-temp-val').textContent = parseFloat(this.value).toFixed(2)">
</div>
</div>
<div id="css-editor-processed-section" style="display:none">
<div class="form-group">
<div class="label-row">

View File

@@ -0,0 +1,90 @@
<!-- Weather Source Editor Modal -->
<div id="weather-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="weather-source-modal-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="weather-source-modal-title" data-i18n="weather_source.add">Add Weather Source</h2>
<button class="modal-close-btn" onclick="closeWeatherSourceModal()" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="weather-source-form" onsubmit="return false;">
<input type="hidden" id="weather-source-id">
<div id="weather-source-error" class="error-message" style="display: none;"></div>
<!-- Name -->
<div class="form-group">
<div class="label-row">
<label for="weather-source-name" data-i18n="weather_source.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="weather_source.name.hint">A descriptive name for this weather source</small>
<input type="text" id="weather-source-name" data-i18n-placeholder="weather_source.name.placeholder" placeholder="My Weather" required>
<div id="weather-source-tags-container"></div>
</div>
<!-- Provider -->
<div class="form-group">
<div class="label-row">
<label for="weather-source-provider" data-i18n="weather_source.provider">Provider:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="weather_source.provider.hint">Weather data provider. Open-Meteo is free and requires no API key.</small>
<select id="weather-source-provider">
<option value="open_meteo">Open-Meteo</option>
</select>
</div>
<!-- Location -->
<div class="form-group">
<div class="label-row">
<label data-i18n="weather_source.location">Location:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="weather_source.location.hint">Your geographic coordinates. Use the auto-detect button or enter manually.</small>
<div class="weather-location-row">
<div class="weather-location-field">
<label for="weather-source-latitude" data-i18n="weather_source.latitude">Lat:</label>
<input type="number" id="weather-source-latitude" min="-90" max="90" step="0.01" value="50.0">
</div>
<div class="weather-location-field">
<label for="weather-source-longitude" data-i18n="weather_source.longitude">Lon:</label>
<input type="number" id="weather-source-longitude" min="-180" max="180" step="0.01" value="0.0">
</div>
<button type="button" class="btn btn-icon btn-secondary" id="weather-source-geolocate-btn"
onclick="weatherSourceGeolocate()" title="Use my location" data-i18n-title="weather_source.use_my_location">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
</button>
</div>
<small id="weather-source-location-name" class="input-hint" style="display:none"></small>
</div>
<!-- Update Interval -->
<div class="form-group">
<div class="label-row">
<label for="weather-source-interval"><span data-i18n="weather_source.update_interval">Update Interval:</span> <span id="weather-source-interval-display">10</span> min</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="weather_source.update_interval.hint">How often to fetch weather data. Lower values give more responsive changes.</small>
<input type="range" id="weather-source-interval" min="60" max="3600" step="60" value="600"
oninput="document.getElementById('weather-source-interval-display').textContent = Math.round(this.value / 60)">
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="weather-source-description" data-i18n="weather_source.description">Description (optional):</label>
</div>
<input type="text" id="weather-source-description" data-i18n-placeholder="weather_source.description.placeholder" placeholder="">
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeWeatherSourceModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-secondary" id="weather-source-test-btn" onclick="testWeatherSource()" title="Test" data-i18n-title="weather_source.test">
<svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg>
</button>
<button class="btn btn-icon btn-primary" onclick="saveWeatherSource()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>