Files
ledgrab/server/src/ledgrab/api/routes/weather_sources.py
T
alexei.dolgolyov 0f5850ef80 feat(ui): customisable card icon for all entity types
Extends the icon-plate work from devices and output targets to every
remaining card type — 18 new entities, 20 in total. Users can now pick
a curated icon (with optional colour override) for any card on any tab,
and the picker reuses the same modal, recent-strip, search, and
category tabs introduced for the device picker.

Foundation:
- icon-picker.ts — replace the hardcoded 2-entry adapter record with a
  Map<EntityType, EntityTypeAdapter> and expose
  registerIconEntityType() + makeSimpleIconAdapter() so each feature
  module owns its own adapter (~6 lines per type).
- bodyExtras hook on adapters, keyed off id, lets discriminated routes
  (output-targets target_type, picture-sources stream_type, audio /
  value / color-strip-sources source_type) accept icon-only PUTs.
- core/card-icon.ts — new makeCardIconFields(type, id, entity) helper
  spreads iconHtml / iconColor / iconAttrs into a mod-card head in one
  line.
- _onDocumentClick now accepts any registered type instead of a
  hardcoded device/target check.

Backend (purely additive — no migrations needed thanks to JSON-blob
storage):
- 18 dataclasses gained icon: str = "" + icon_color: str = "" with
  emit-when-truthy serialisation and "" defaults on load.
- All matching Create / Update / Response Pydantic schemas gained the
  fields with the standard Optional[str] + max_length=64/32 +
  description set.
- All routes' response builders use
  getattr(entity, "icon", "") or "" so existing rows render unchanged.
- ValueSource and CSS handle icon/icon_color on the base class so all
  source-type subclasses inherit them automatically.

Frontend wiring (12 modules):
- streams.ts — picture sources, capture templates, PP templates,
  CSPT, audio sources, audio templates, gradients (built-in
  gradients keep no plate).
- automations, scene-presets, sync-clocks, weather-sources,
  value-sources, mqtt-sources, home-assistant-sources,
  game-integration, audio-processing-templates, assets,
  color-strips/cards.
- pattern-templates skipped — uses the legacy wrapCard({content,
  actions}) string API, separate migration.

Dashboard cards now also display the chosen icon:
- Targets already had it (with device inheritance for LED targets).
- Sync clocks, automations, and scene presets gained the same plate
  via a shared _dashboardIconPlate helper that mirrors the mod-card
  layout (mod-head--with-icon class flips on when present).

i18n: 20 new device.icon.entity.<type> labels in en/ru/zh.

Verification:
- ruff check src/ tests/ — clean.
- npx tsc --noEmit — clean.
- npm run build — 2.6 MB bundle.
- pytest tests/ --no-cov — 949 passed (no regressions).

Pending: manual smoke test on each card type — open picker, save, and
confirm the channel-color preview matches the live card.
2026-05-09 16:19:20 +03:00

183 lines
5.7 KiB
Python

"""Weather source routes: CRUD + test endpoint."""
from fastapi import APIRouter, Depends, HTTPException
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_weather_manager,
get_weather_source_store,
)
from ledgrab.api.schemas.weather_sources import (
WeatherSourceCreate,
WeatherSourceListResponse,
WeatherSourceResponse,
WeatherSourceUpdate,
WeatherTestResponse,
)
from ledgrab.core.weather.weather_manager import WeatherManager
from ledgrab.core.weather.weather_provider import WMO_CONDITION_NAMES
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.weather_source import WeatherSource
from ledgrab.storage.weather_source_store import WeatherSourceStore
from ledgrab.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", []),
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
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,
icon=data.icon,
icon_color=data.icon_color,
)
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,
icon=data.icon,
icon_color=data.icon_color,
)
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,
)