feat: add weather source entity and weather-reactive CSS source type
Some checks failed
Lint & Test / test (push) Failing after 34s
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:
47
TODO.md
Normal file
47
TODO.md
Normal 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
|
||||||
@@ -24,6 +24,7 @@ from .routes.webhooks import router as webhooks_router
|
|||||||
from .routes.sync_clocks import router as sync_clocks_router
|
from .routes.sync_clocks import router as sync_clocks_router
|
||||||
from .routes.color_strip_processing import router as cspt_router
|
from .routes.color_strip_processing import router as cspt_router
|
||||||
from .routes.gradients import router as gradients_router
|
from .routes.gradients import router as gradients_router
|
||||||
|
from .routes.weather_sources import router as weather_sources_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(system_router)
|
router.include_router(system_router)
|
||||||
@@ -48,5 +49,6 @@ router.include_router(webhooks_router)
|
|||||||
router.include_router(sync_clocks_router)
|
router.include_router(sync_clocks_router)
|
||||||
router.include_router(cspt_router)
|
router.include_router(cspt_router)
|
||||||
router.include_router(gradients_router)
|
router.include_router(gradients_router)
|
||||||
|
router.include_router(weather_sources_router)
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ from wled_controller.storage.scene_preset_store import ScenePresetStore
|
|||||||
from wled_controller.storage.sync_clock_store import SyncClockStore
|
from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||||
from wled_controller.storage.gradient_store import GradientStore
|
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.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.backup.auto_backup import AutoBackupEngine
|
||||||
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
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")
|
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 ────────────────────────────────────────────────────────
|
# ── Event helper ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -163,6 +173,8 @@ def init_dependencies(
|
|||||||
sync_clock_manager: SyncClockManager | None = None,
|
sync_clock_manager: SyncClockManager | None = None,
|
||||||
cspt_store: ColorStripProcessingTemplateStore | None = None,
|
cspt_store: ColorStripProcessingTemplateStore | None = None,
|
||||||
gradient_store: GradientStore | None = None,
|
gradient_store: GradientStore | None = None,
|
||||||
|
weather_source_store: WeatherSourceStore | None = None,
|
||||||
|
weather_manager: WeatherManager | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize global dependencies."""
|
"""Initialize global dependencies."""
|
||||||
_deps.update({
|
_deps.update({
|
||||||
@@ -185,4 +197,6 @@ def init_dependencies(
|
|||||||
"sync_clock_manager": sync_clock_manager,
|
"sync_clock_manager": sync_clock_manager,
|
||||||
"cspt_store": cspt_store,
|
"cspt_store": cspt_store,
|
||||||
"gradient_store": gradient_store,
|
"gradient_store": gradient_store,
|
||||||
|
"weather_source_store": weather_source_store,
|
||||||
|
"weather_manager": weather_manager,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ STORE_MAP = {
|
|||||||
"automations": "automations_file",
|
"automations": "automations_file",
|
||||||
"scene_presets": "scene_presets_file",
|
"scene_presets": "scene_presets_file",
|
||||||
"gradients": "gradients_file",
|
"gradients": "gradients_file",
|
||||||
|
"weather_sources": "weather_sources_file",
|
||||||
}
|
}
|
||||||
|
|
||||||
_SERVER_DIR = Path(__file__).resolve().parents[4]
|
_SERVER_DIR = Path(__file__).resolve().parents[4]
|
||||||
|
|||||||
157
server/src/wled_controller/api/routes/weather_sources.py
Normal file
157
server/src/wled_controller/api/routes/weather_sources.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -54,7 +54,7 @@ class ColorStripSourceCreate(BaseModel):
|
|||||||
"""Request to create a color strip source."""
|
"""Request to create a color strip source."""
|
||||||
|
|
||||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
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-type fields
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
|
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)
|
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
|
# processed-type fields
|
||||||
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
|
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)")
|
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
|
# sync clock
|
||||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
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")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
@@ -180,6 +183,9 @@ class ColorStripSourceUpdate(BaseModel):
|
|||||||
# processed-type fields
|
# processed-type fields
|
||||||
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
|
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)")
|
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
|
# sync clock
|
||||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
@@ -248,6 +254,9 @@ class ColorStripSourceResponse(BaseModel):
|
|||||||
# processed-type fields
|
# processed-type fields
|
||||||
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
|
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")
|
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
|
# sync clock
|
||||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
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")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|||||||
65
server/src/wled_controller/api/schemas/weather_sources.py
Normal file
65
server/src/wled_controller/api/schemas/weather_sources.py
Normal 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)")
|
||||||
@@ -42,6 +42,7 @@ class StorageConfig(BaseSettings):
|
|||||||
color_strip_processing_templates_file: str = "data/color_strip_processing_templates.json"
|
color_strip_processing_templates_file: str = "data/color_strip_processing_templates.json"
|
||||||
sync_clocks_file: str = "data/sync_clocks.json"
|
sync_clocks_file: str = "data/sync_clocks.json"
|
||||||
gradients_file: str = "data/gradients.json"
|
gradients_file: str = "data/gradients.json"
|
||||||
|
weather_sources_file: str = "data/weather_sources.json"
|
||||||
|
|
||||||
|
|
||||||
class MQTTConfig(BaseSettings):
|
class MQTTConfig(BaseSettings):
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class ColorStripStreamManager:
|
|||||||
keyed by ``{css_id}:{consumer_id}``.
|
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:
|
Args:
|
||||||
color_strip_store: ColorStripStore for resolving source configs
|
color_strip_store: ColorStripStore for resolving source configs
|
||||||
@@ -90,6 +90,7 @@ class ColorStripStreamManager:
|
|||||||
self._value_stream_manager = value_stream_manager
|
self._value_stream_manager = value_stream_manager
|
||||||
self._cspt_store = cspt_store
|
self._cspt_store = cspt_store
|
||||||
self._gradient_store = gradient_store
|
self._gradient_store = gradient_store
|
||||||
|
self._weather_manager = weather_manager
|
||||||
self._streams: Dict[str, _ColorStripEntry] = {}
|
self._streams: Dict[str, _ColorStripEntry] = {}
|
||||||
|
|
||||||
def _inject_clock(self, css_stream, source) -> Optional[str]:
|
def _inject_clock(self, css_stream, source) -> Optional[str]:
|
||||||
@@ -172,6 +173,9 @@ class ColorStripStreamManager:
|
|||||||
css_stream = MappedColorStripStream(source, self)
|
css_stream = MappedColorStripStream(source, self)
|
||||||
elif source.source_type == "processed":
|
elif source.source_type == "processed":
|
||||||
css_stream = ProcessedColorStripStream(source, self, self._cspt_store)
|
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:
|
else:
|
||||||
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
|
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
|
||||||
if not stream_cls:
|
if not stream_cls:
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class ProcessorDependencies:
|
|||||||
sync_clock_manager: object = None
|
sync_clock_manager: object = None
|
||||||
cspt_store: object = None
|
cspt_store: object = None
|
||||||
gradient_store: object = None
|
gradient_store: object = None
|
||||||
|
weather_manager: object = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -131,6 +132,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
sync_clock_manager=deps.sync_clock_manager,
|
sync_clock_manager=deps.sync_clock_manager,
|
||||||
cspt_store=deps.cspt_store,
|
cspt_store=deps.cspt_store,
|
||||||
gradient_store=deps.gradient_store,
|
gradient_store=deps.gradient_store,
|
||||||
|
weather_manager=deps.weather_manager,
|
||||||
)
|
)
|
||||||
self._value_stream_manager = ValueStreamManager(
|
self._value_stream_manager = ValueStreamManager(
|
||||||
value_source_store=deps.value_source_store,
|
value_source_store=deps.value_source_store,
|
||||||
|
|||||||
282
server/src/wled_controller/core/processing/weather_stream.py
Normal file
282
server/src/wled_controller/core/processing/weather_stream.py
Normal 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
|
||||||
0
server/src/wled_controller/core/weather/__init__.py
Normal file
0
server/src/wled_controller/core/weather/__init__.py
Normal file
183
server/src/wled_controller/core/weather/weather_manager.py
Normal file
183
server/src/wled_controller/core/weather/weather_manager.py
Normal 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")
|
||||||
123
server/src/wled_controller/core/weather/weather_provider.py
Normal file
123
server/src/wled_controller/core/weather/weather_provider.py
Normal 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",
|
||||||
|
}
|
||||||
@@ -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.sync_clock_store import SyncClockStore
|
||||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||||
from wled_controller.storage.gradient_store import GradientStore
|
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.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.automations.automation_engine import AutomationEngine
|
||||||
from wled_controller.core.mqtt.mqtt_service import MQTTService
|
from wled_controller.core.mqtt.mqtt_service import MQTTService
|
||||||
from wled_controller.core.devices.mqtt_client import set_mqtt_service
|
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)
|
cspt_store = ColorStripProcessingTemplateStore(config.storage.color_strip_processing_templates_file)
|
||||||
gradient_store = GradientStore(config.storage.gradients_file)
|
gradient_store = GradientStore(config.storage.gradients_file)
|
||||||
gradient_store.migrate_palette_references(color_strip_store)
|
gradient_store.migrate_palette_references(color_strip_store)
|
||||||
|
weather_source_store = WeatherSourceStore(config.storage.weather_sources_file)
|
||||||
sync_clock_manager = SyncClockManager(sync_clock_store)
|
sync_clock_manager = SyncClockManager(sync_clock_store)
|
||||||
|
weather_manager = WeatherManager(weather_source_store)
|
||||||
|
|
||||||
processor_manager = ProcessorManager(
|
processor_manager = ProcessorManager(
|
||||||
ProcessorDependencies(
|
ProcessorDependencies(
|
||||||
@@ -88,6 +92,7 @@ processor_manager = ProcessorManager(
|
|||||||
sync_clock_manager=sync_clock_manager,
|
sync_clock_manager=sync_clock_manager,
|
||||||
cspt_store=cspt_store,
|
cspt_store=cspt_store,
|
||||||
gradient_store=gradient_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,
|
picture_source_store, output_target_store, pattern_template_store,
|
||||||
color_strip_store, audio_source_store, audio_template_store,
|
color_strip_store, audio_source_store, audio_template_store,
|
||||||
value_source_store, automation_store, scene_preset_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
|
saved = 0
|
||||||
for store in all_stores:
|
for store in all_stores:
|
||||||
@@ -195,6 +200,8 @@ async def lifespan(app: FastAPI):
|
|||||||
sync_clock_manager=sync_clock_manager,
|
sync_clock_manager=sync_clock_manager,
|
||||||
cspt_store=cspt_store,
|
cspt_store=cspt_store,
|
||||||
gradient_store=gradient_store,
|
gradient_store=gradient_store,
|
||||||
|
weather_source_store=weather_source_store,
|
||||||
|
weather_manager=weather_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register devices in processor manager for health monitoring
|
# Register devices in processor manager for health monitoring
|
||||||
@@ -258,6 +265,12 @@ async def lifespan(app: FastAPI):
|
|||||||
# where no CRUD happened during the session.
|
# where no CRUD happened during the session.
|
||||||
_save_all_stores()
|
_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
|
# Stop auto-backup engine
|
||||||
try:
|
try:
|
||||||
await auto_backup_engine.stop()
|
await auto_backup_engine.stop()
|
||||||
|
|||||||
@@ -1738,6 +1738,31 @@
|
|||||||
background: var(--border-color);
|
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 {
|
.composite-layer-drag-handle:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const _colorStripTypeIcons = {
|
|||||||
notification: _svg(P.bellRing),
|
notification: _svg(P.bellRing),
|
||||||
daylight: _svg(P.sun),
|
daylight: _svg(P.sun),
|
||||||
candlelight: _svg(P.flame),
|
candlelight: _svg(P.flame),
|
||||||
|
weather: _svg(P.cloudSun),
|
||||||
processed: _svg(P.sparkles),
|
processed: _svg(P.sparkles),
|
||||||
};
|
};
|
||||||
const _valueSourceTypeIcons = {
|
const _valueSourceTypeIcons = {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { DataCache } from './cache.ts';
|
|||||||
import type {
|
import type {
|
||||||
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
||||||
ValueSource, AudioSource, PictureSource, ScenePreset,
|
ValueSource, AudioSource, PictureSource, ScenePreset,
|
||||||
SyncClock, Automation, Display, FilterDef, EngineInfo,
|
SyncClock, WeatherSource, Automation, Display, FilterDef, EngineInfo,
|
||||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||||
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
||||||
} from '../types.ts';
|
} from '../types.ts';
|
||||||
@@ -225,6 +225,7 @@ export let _cachedValueSources: ValueSource[] = [];
|
|||||||
|
|
||||||
// Sync clocks
|
// Sync clocks
|
||||||
export let _cachedSyncClocks: SyncClock[] = [];
|
export let _cachedSyncClocks: SyncClock[] = [];
|
||||||
|
export let _cachedWeatherSources: WeatherSource[] = [];
|
||||||
|
|
||||||
// Automations
|
// Automations
|
||||||
export let _automationsCache: Automation[] | null = null;
|
export let _automationsCache: Automation[] | null = null;
|
||||||
@@ -282,6 +283,12 @@ export const syncClocksCache = new DataCache<SyncClock[]>({
|
|||||||
});
|
});
|
||||||
syncClocksCache.subscribe(v => { _cachedSyncClocks = v; });
|
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[]>({
|
export const filtersCache = new DataCache<FilterDef[]>({
|
||||||
endpoint: '/filters',
|
endpoint: '/filters',
|
||||||
extractData: json => json.filters || [],
|
extractData: json => json.filters || [],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
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 { t } from '../core/i18n.ts';
|
||||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
@@ -127,7 +127,7 @@ let _processedTemplateEntitySelect: any = null;
|
|||||||
const CSS_TYPE_KEYS = [
|
const CSS_TYPE_KEYS = [
|
||||||
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
|
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
|
||||||
'effect', 'composite', 'mapped', 'audio',
|
'effect', 'composite', 'mapped', 'audio',
|
||||||
'api_input', 'notification', 'daylight', 'candlelight', 'processed',
|
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed',
|
||||||
];
|
];
|
||||||
|
|
||||||
function _buildCSSTypeItems() {
|
function _buildCSSTypeItems() {
|
||||||
@@ -172,6 +172,7 @@ const CSS_SECTION_MAP: Record<string, string> = {
|
|||||||
'notification': 'css-editor-notification-section',
|
'notification': 'css-editor-notification-section',
|
||||||
'daylight': 'css-editor-daylight-section',
|
'daylight': 'css-editor-daylight-section',
|
||||||
'candlelight': 'css-editor-candlelight-section',
|
'candlelight': 'css-editor-candlelight-section',
|
||||||
|
'weather': 'css-editor-weather-section',
|
||||||
'processed': 'css-editor-processed-section',
|
'processed': 'css-editor-processed-section',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,6 +185,7 @@ const CSS_TYPE_SETUP: Record<string, () => void> = {
|
|||||||
gradient: () => { _ensureGradientPresetIconSelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); },
|
gradient: () => { _ensureGradientPresetIconSelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); },
|
||||||
notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); },
|
notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); },
|
||||||
candlelight: () => _ensureCandleTypeIconSelect(),
|
candlelight: () => _ensureCandleTypeIconSelect(),
|
||||||
|
weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); },
|
||||||
composite: () => compositeRenderList(),
|
composite: () => compositeRenderList(),
|
||||||
mapped: () => _mappedRenderList(),
|
mapped: () => _mappedRenderList(),
|
||||||
};
|
};
|
||||||
@@ -238,7 +240,7 @@ export function onCSSTypeChange() {
|
|||||||
hasLedCount.includes(type) ? '' : 'none';
|
hasLedCount.includes(type) ? '' : 'none';
|
||||||
|
|
||||||
// Sync clock — shown for animated types
|
// 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';
|
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
|
||||||
if (clockTypes.includes(type)) _populateClockDropdown();
|
if (clockTypes.includes(type)) _populateClockDropdown();
|
||||||
|
|
||||||
@@ -272,6 +274,29 @@ export function onCSSClockChange() {
|
|||||||
// No-op: speed sliders removed; speed is now clock-only
|
// 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() {
|
function _populateProcessedSelectors() {
|
||||||
const editingId = (document.getElementById('css-editor-id') as HTMLInputElement).value;
|
const editingId = (document.getElementById('css-editor-id') as HTMLInputElement).value;
|
||||||
const allSources = (colorStripSourcesCache.data || []) as any[];
|
const allSources = (colorStripSourcesCache.data || []) as any[];
|
||||||
@@ -927,7 +952,7 @@ type CardPropsRenderer = (source: ColorStripSource, opts: {
|
|||||||
|
|
||||||
const NON_PICTURE_TYPES = new Set([
|
const NON_PICTURE_TYPES = new Set([
|
||||||
'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped',
|
'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> = {
|
const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||||
@@ -1055,6 +1080,17 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
|||||||
${clockBadge}
|
${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) => {
|
processed: (source) => {
|
||||||
const inputSrc = ((colorStripSourcesCache.data || []) as any[]).find(s => s.id === source.input_source_id);
|
const inputSrc = ((colorStripSourcesCache.data || []) as any[]).find(s => s.id === source.input_source_id);
|
||||||
const inputName = inputSrc?.name || 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: {
|
processed: {
|
||||||
async load(css) {
|
async load(css) {
|
||||||
await csptCache.fetch();
|
await csptCache.fetch();
|
||||||
@@ -1726,7 +1795,7 @@ export async function saveCSSEditor() {
|
|||||||
if (!cssId) payload.source_type = knownType ? sourceType : 'picture';
|
if (!cssId) payload.source_type = knownType ? sourceType : 'picture';
|
||||||
|
|
||||||
// Attach clock_id for animated types
|
// 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)) {
|
if (clockTypes.includes(sourceType)) {
|
||||||
const clockVal = (document.getElementById('css-editor-clock') as HTMLInputElement).value;
|
const clockVal = (document.getElementById('css-editor-clock') as HTMLInputElement).value;
|
||||||
payload.clock_id = clockVal || null;
|
payload.clock_id = clockVal || null;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
_cachedAudioSources,
|
_cachedAudioSources,
|
||||||
_cachedValueSources,
|
_cachedValueSources,
|
||||||
_cachedSyncClocks,
|
_cachedSyncClocks,
|
||||||
|
_cachedWeatherSources,
|
||||||
_cachedAudioTemplates,
|
_cachedAudioTemplates,
|
||||||
_cachedCSPTemplates,
|
_cachedCSPTemplates,
|
||||||
_csptModalFilters, set_csptModalFilters,
|
_csptModalFilters, set_csptModalFilters,
|
||||||
@@ -34,7 +35,7 @@ import {
|
|||||||
_sourcesLoading, set_sourcesLoading,
|
_sourcesLoading, set_sourcesLoading,
|
||||||
apiKey,
|
apiKey,
|
||||||
streamsCache, ppTemplatesCache, captureTemplatesCache,
|
streamsCache, ppTemplatesCache, captureTemplatesCache,
|
||||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
|
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, filtersCache,
|
||||||
colorStripSourcesCache,
|
colorStripSourcesCache,
|
||||||
csptCache, stripFiltersCache,
|
csptCache, stripFiltersCache,
|
||||||
gradientsCache, GradientEntity,
|
gradientsCache, GradientEntity,
|
||||||
@@ -49,6 +50,7 @@ import { TreeNav } from '../core/tree-nav.ts';
|
|||||||
import { updateSubTabHash } from './tabs.ts';
|
import { updateSubTabHash } from './tabs.ts';
|
||||||
import { createValueSourceCard } from './value-sources.ts';
|
import { createValueSourceCard } from './value-sources.ts';
|
||||||
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
|
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
|
||||||
|
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
||||||
import { createColorStripCard } from './color-strips.ts';
|
import { createColorStripCard } from './color-strips.ts';
|
||||||
import { initAudioSourceDelegation } from './audio-sources.ts';
|
import { initAudioSourceDelegation } from './audio-sources.ts';
|
||||||
import {
|
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 _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 _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 _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') }];
|
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 ──
|
// ── 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 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 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 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 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 _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 });
|
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(),
|
audioSourcesCache.fetch(),
|
||||||
valueSourcesCache.fetch(),
|
valueSourcesCache.fetch(),
|
||||||
syncClocksCache.fetch(),
|
syncClocksCache.fetch(),
|
||||||
|
weatherSourcesCache.fetch(),
|
||||||
audioTemplatesCache.fetch(),
|
audioTemplatesCache.fetch(),
|
||||||
colorStripSourcesCache.fetch(),
|
colorStripSourcesCache.fetch(),
|
||||||
csptCache.fetch(),
|
csptCache.fetch(),
|
||||||
@@ -274,6 +279,7 @@ const _streamSectionMap = {
|
|||||||
audio_templates: [csAudioTemplates],
|
audio_templates: [csAudioTemplates],
|
||||||
value: [csValueSources],
|
value: [csValueSources],
|
||||||
sync: [csSyncClocks],
|
sync: [csSyncClocks],
|
||||||
|
weather: [csWeatherSources],
|
||||||
};
|
};
|
||||||
|
|
||||||
type StreamCardRenderer = (stream: any) => string;
|
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: '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: '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: '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
|
// Build tree navigation structure
|
||||||
@@ -533,6 +540,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
children: [
|
children: [
|
||||||
{ key: 'value', titleKey: 'streams.group.value', icon: ICON_VALUE_SOURCE, count: _cachedValueSources.length },
|
{ 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: '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 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 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 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) })));
|
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
|
||||||
|
|
||||||
if (csRawStreams.isMounted()) {
|
if (csRawStreams.isMounted()) {
|
||||||
@@ -681,6 +690,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
audio_templates: _cachedAudioTemplates.length,
|
audio_templates: _cachedAudioTemplates.length,
|
||||||
value: _cachedValueSources.length,
|
value: _cachedValueSources.length,
|
||||||
sync: _cachedSyncClocks.length,
|
sync: _cachedSyncClocks.length,
|
||||||
|
weather: _cachedWeatherSources.length,
|
||||||
});
|
});
|
||||||
csRawStreams.reconcile(rawStreamItems);
|
csRawStreams.reconcile(rawStreamItems);
|
||||||
csRawTemplates.reconcile(rawTemplateItems);
|
csRawTemplates.reconcile(rawTemplateItems);
|
||||||
@@ -696,6 +706,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
csVideoStreams.reconcile(videoItems);
|
csVideoStreams.reconcile(videoItems);
|
||||||
csValueSources.reconcile(valueItems);
|
csValueSources.reconcile(valueItems);
|
||||||
csSyncClocks.reconcile(syncClockItems);
|
csSyncClocks.reconcile(syncClockItems);
|
||||||
|
csWeatherSources.reconcile(weatherSourceItems);
|
||||||
} else {
|
} else {
|
||||||
// First render: build full HTML
|
// First render: build full HTML
|
||||||
const panels = tabs.map(tab => {
|
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 === 'audio_templates') panelContent = csAudioTemplates.render(audioTemplateItems);
|
||||||
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
|
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
|
||||||
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
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 if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
|
||||||
else panelContent = csStaticStreams.render(staticItems);
|
else panelContent = csStaticStreams.render(staticItems);
|
||||||
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
|
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
container.innerHTML = panels;
|
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)
|
// Event delegation for card actions (replaces inline onclick handlers)
|
||||||
initSyncClockDelegation(container);
|
initSyncClockDelegation(container);
|
||||||
|
initWeatherSourceDelegation(container);
|
||||||
initAudioSourceDelegation(container);
|
initAudioSourceDelegation(container);
|
||||||
|
|
||||||
// Render tree sidebar with expand/collapse buttons
|
// Render tree sidebar with expand/collapse buttons
|
||||||
@@ -738,6 +751,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
'audio-templates': 'audio_templates',
|
'audio-templates': 'audio_templates',
|
||||||
'value-sources': 'value',
|
'value-sources': 'value',
|
||||||
'sync-clocks': 'sync',
|
'sync-clocks': 'sync',
|
||||||
|
'weather-sources': 'weather',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
329
server/src/wled_controller/static/js/features/weather-sources.ts
Normal file
329
server/src/wled_controller/static/js/features/weather-sources.ts
Normal 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;
|
||||||
@@ -223,6 +223,10 @@ export interface ColorStripSource {
|
|||||||
// Processed
|
// Processed
|
||||||
input_source_id?: string;
|
input_source_id?: string;
|
||||||
processing_template_id?: string;
|
processing_template_id?: string;
|
||||||
|
|
||||||
|
// Weather
|
||||||
|
weather_source_id?: string;
|
||||||
|
temperature_influence?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pattern Template ──────────────────────────────────────────
|
// ── Pattern Template ──────────────────────────────────────────
|
||||||
@@ -384,6 +388,25 @@ export interface SyncClock {
|
|||||||
updated_at: string;
|
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 ────────────────────────────────────────────────
|
// ── Automation ────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ConditionType =
|
export type ConditionType =
|
||||||
|
|||||||
@@ -1140,6 +1140,16 @@
|
|||||||
"color_strip.type.candlelight": "Candlelight",
|
"color_strip.type.candlelight": "Candlelight",
|
||||||
"color_strip.type.candlelight.desc": "Realistic flickering candle simulation",
|
"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.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": "Base Color:",
|
||||||
"color_strip.candlelight.color.hint": "The warm base color of the candle flame. Default is a natural warm amber.",
|
"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:",
|
"color_strip.candlelight.intensity": "Flicker Intensity:",
|
||||||
@@ -1704,6 +1714,35 @@
|
|||||||
"sync_clock.reset_done": "Clock reset to zero",
|
"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.delete.confirm": "Delete this sync clock? Linked sources will lose synchronization and run at default speed.",
|
||||||
"sync_clock.elapsed": "Elapsed time",
|
"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": "Sync Clock:",
|
||||||
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the 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",
|
"graph.title": "Graph",
|
||||||
@@ -1814,6 +1853,7 @@
|
|||||||
"section.empty.color_strips": "No color strips yet. Click + to add one.",
|
"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.value_sources": "No value sources yet. Click + to add one.",
|
||||||
"section.empty.sync_clocks": "No sync clocks 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.cspt": "No CSS processing templates yet. Click + to add one.",
|
||||||
"section.empty.automations": "No automations 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.",
|
"section.empty.scenes": "No scene presets yet. Click + to add one.",
|
||||||
|
|||||||
@@ -1117,6 +1117,16 @@
|
|||||||
"color_strip.type.candlelight": "Свечи",
|
"color_strip.type.candlelight": "Свечи",
|
||||||
"color_strip.type.candlelight.desc": "Реалистичная имитация мерцания свечей",
|
"color_strip.type.candlelight.desc": "Реалистичная имитация мерцания свечей",
|
||||||
"color_strip.type.candlelight.hint": "Реалистичное мерцание свечей с тёплыми тонами и органическими паттернами.",
|
"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": "Базовый цвет:",
|
||||||
"color_strip.candlelight.color.hint": "Тёплый базовый цвет пламени свечи. По умолчанию — натуральный тёплый янтарь.",
|
"color_strip.candlelight.color.hint": "Тёплый базовый цвет пламени свечи. По умолчанию — натуральный тёплый янтарь.",
|
||||||
"color_strip.candlelight.intensity": "Интенсивность мерцания:",
|
"color_strip.candlelight.intensity": "Интенсивность мерцания:",
|
||||||
@@ -1631,6 +1641,36 @@
|
|||||||
"sync_clock.reset_done": "Часы сброшены на ноль",
|
"sync_clock.reset_done": "Часы сброшены на ноль",
|
||||||
"sync_clock.delete.confirm": "Удалить эти часы синхронизации? Привязанные источники потеряют синхронизацию и будут работать на скорости по умолчанию.",
|
"sync_clock.delete.confirm": "Удалить эти часы синхронизации? Привязанные источники потеряют синхронизацию и будут работать на скорости по умолчанию.",
|
||||||
"sync_clock.elapsed": "Прошло времени",
|
"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": "Часы синхронизации:",
|
||||||
"color_strip.clock.hint": "Привязка к часам для синхронизации анимации между источниками. Скорость управляется на часах.",
|
"color_strip.clock.hint": "Привязка к часам для синхронизации анимации между источниками. Скорость управляется на часах.",
|
||||||
"graph.title": "Граф",
|
"graph.title": "Граф",
|
||||||
|
|||||||
@@ -1117,6 +1117,16 @@
|
|||||||
"color_strip.type.candlelight": "烛光",
|
"color_strip.type.candlelight": "烛光",
|
||||||
"color_strip.type.candlelight.desc": "逼真的烛光闪烁模拟",
|
"color_strip.type.candlelight.desc": "逼真的烛光闪烁模拟",
|
||||||
"color_strip.type.candlelight.hint": "在所有LED上模拟逼真的蜡烛闪烁,具有温暖色调和有机闪烁模式。",
|
"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": "基础颜色:",
|
||||||
"color_strip.candlelight.color.hint": "蜡烛火焰的温暖基础颜色。默认为自然温暖的琥珀色。",
|
"color_strip.candlelight.color.hint": "蜡烛火焰的温暖基础颜色。默认为自然温暖的琥珀色。",
|
||||||
"color_strip.candlelight.intensity": "闪烁强度:",
|
"color_strip.candlelight.intensity": "闪烁强度:",
|
||||||
@@ -1631,6 +1641,36 @@
|
|||||||
"sync_clock.reset_done": "时钟已重置为零",
|
"sync_clock.reset_done": "时钟已重置为零",
|
||||||
"sync_clock.delete.confirm": "删除此同步时钟?关联的源将失去同步并以默认速度运行。",
|
"sync_clock.delete.confirm": "删除此同步时钟?关联的源将失去同步并以默认速度运行。",
|
||||||
"sync_clock.elapsed": "已用时间",
|
"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": "同步时钟:",
|
||||||
"color_strip.clock.hint": "关联同步时钟以在多个源之间同步动画。速度在时钟上控制。",
|
"color_strip.clock.hint": "关联同步时钟以在多个源之间同步动画。速度在时钟上控制。",
|
||||||
"graph.title": "图表",
|
"graph.title": "图表",
|
||||||
|
|||||||
@@ -1084,6 +1084,60 @@ class ProcessedColorStripSource(ColorStripSource):
|
|||||||
self.processing_template_id = resolve_ref(processing_template_id, self.processing_template_id)
|
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 --
|
# -- Source type registry --
|
||||||
# Maps source_type string to its subclass for factory dispatch.
|
# Maps source_type string to its subclass for factory dispatch.
|
||||||
_SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = {
|
_SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = {
|
||||||
@@ -1101,4 +1155,5 @@ _SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = {
|
|||||||
"daylight": DaylightColorStripSource,
|
"daylight": DaylightColorStripSource,
|
||||||
"candlelight": CandlelightColorStripSource,
|
"candlelight": CandlelightColorStripSource,
|
||||||
"processed": ProcessedColorStripSource,
|
"processed": ProcessedColorStripSource,
|
||||||
|
"weather": WeatherColorStripSource,
|
||||||
}
|
}
|
||||||
|
|||||||
67
server/src/wled_controller/storage/weather_source.py
Normal file
67
server/src/wled_controller/storage/weather_source.py
Normal 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),
|
||||||
|
)
|
||||||
120
server/src/wled_controller/storage/weather_source_store.py
Normal file
120
server/src/wled_controller/storage/weather_source_store.py
Normal 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
|
||||||
@@ -210,6 +210,7 @@
|
|||||||
{% include 'modals/value-source-editor.html' %}
|
{% include 'modals/value-source-editor.html' %}
|
||||||
{% include 'modals/test-value-source.html' %}
|
{% include 'modals/test-value-source.html' %}
|
||||||
{% include 'modals/sync-clock-editor.html' %}
|
{% include 'modals/sync-clock-editor.html' %}
|
||||||
|
{% include 'modals/weather-source-editor.html' %}
|
||||||
{% include 'modals/settings.html' %}
|
{% include 'modals/settings.html' %}
|
||||||
|
|
||||||
{% include 'partials/tutorial-overlay.html' %}
|
{% include 'partials/tutorial-overlay.html' %}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
<option value="notification" data-i18n="color_strip.type.notification">Notification</option>
|
<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="daylight" data-i18n="color_strip.type.daylight">Daylight Cycle</option>
|
||||||
<option value="candlelight" data-i18n="color_strip.type.candlelight">Candlelight</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>
|
<option value="processed" data-i18n="color_strip.type.processed">Processed</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -566,6 +567,38 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Processed type fields -->
|
<!-- 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 id="css-editor-processed-section" style="display:none">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
|
|||||||
@@ -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">✕</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">✕</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">✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user