feat: new value source types (HA entity, gradient map, strip extract) + UI fixes
Lint & Test / test (push) Successful in 1m27s
Lint & Test / test (push) Successful in 1m27s
New value source types: - ha_entity: reads numeric values from HA entity state/attribute, normalizes via min/max range, applies EMA smoothing. EntitySelect for HA connection and entity selection with live entity list fetching. - gradient_map: maps a float value source (0-1) through a gradient entity. EntitySelect for both input source and gradient with inline previews. - css_extract: extracts single color by averaging LED range from a color strip source. EntitySelect for source selection. Value source type picker: - Filter tabs (All / Numeric / Color) above the icon grid - showTypePicker extended with filterTabs + onFilterChange support Palette selectors converted to EntitySelect: - Effect palette, gradient preset, and audio palette selectors now use command-palette style EntitySelect with gradient strip previews Tab indicator fixes: - Icon now updates on tab switch (was passing no args to updateTabIndicator) - Visible with any background effect active, not just Noise Field - Noise Field is the default background effect for new users Dashboard section collapse fix: - Split header into clickable toggle (chevron+label) and non-clickable actions area — buttons no longer trigger collapse/expand Discriminated union fix (422 errors): - source_type/target_type now always included in update payloads for: CSS editor, LED target, HA light target, simple calibration, advanced calibration
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
"""Audio source routes: CRUD for audio sources + real-time test WebSocket."""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
@@ -19,8 +19,16 @@ from wled_controller.api.schemas.audio_sources import (
|
||||
AudioSourceListResponse,
|
||||
AudioSourceResponse,
|
||||
AudioSourceUpdate,
|
||||
BandExtractAudioSourceResponse,
|
||||
MonoAudioSourceResponse,
|
||||
MultichannelAudioSourceResponse,
|
||||
)
|
||||
from wled_controller.storage.audio_source import (
|
||||
AudioSource,
|
||||
BandExtractAudioSource,
|
||||
MonoAudioSource,
|
||||
MultichannelAudioSource,
|
||||
)
|
||||
from wled_controller.storage.audio_source import AudioSource
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.utils import get_logger
|
||||
@@ -31,31 +39,68 @@ logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
_RESPONSE_MAP = {
|
||||
MultichannelAudioSource: lambda s: MultichannelAudioSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
device_index=s.device_index,
|
||||
is_loopback=s.is_loopback,
|
||||
audio_template_id=s.audio_template_id,
|
||||
),
|
||||
MonoAudioSource: lambda s: MonoAudioSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
audio_source_id=s.audio_source_id,
|
||||
channel=s.channel,
|
||||
),
|
||||
BandExtractAudioSource: lambda s: BandExtractAudioSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
audio_source_id=s.audio_source_id,
|
||||
band=s.band,
|
||||
freq_low=s.freq_low,
|
||||
freq_high=s.freq_high,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _to_response(source: AudioSource) -> AudioSourceResponse:
|
||||
"""Convert an AudioSource to an AudioSourceResponse."""
|
||||
return AudioSourceResponse(
|
||||
id=source.id,
|
||||
name=source.name,
|
||||
source_type=source.source_type,
|
||||
device_index=getattr(source, "device_index", None),
|
||||
is_loopback=getattr(source, "is_loopback", None),
|
||||
audio_template_id=getattr(source, "audio_template_id", None),
|
||||
audio_source_id=getattr(source, "audio_source_id", None),
|
||||
channel=getattr(source, "channel", None),
|
||||
band=getattr(source, "band", None),
|
||||
freq_low=getattr(source, "freq_low", None),
|
||||
freq_high=getattr(source, "freq_high", None),
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
"""Convert an AudioSource dataclass to the matching response schema."""
|
||||
builder = _RESPONSE_MAP.get(type(source))
|
||||
if builder is None:
|
||||
# Fallback for unknown types — return as multichannel
|
||||
return MultichannelAudioSourceResponse(
|
||||
id=source.id,
|
||||
name=source.name,
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
device_index=getattr(source, "device_index", -1),
|
||||
is_loopback=getattr(source, "is_loopback", True),
|
||||
audio_template_id=getattr(source, "audio_template_id", None),
|
||||
)
|
||||
return builder(source)
|
||||
|
||||
|
||||
@router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"])
|
||||
async def list_audio_sources(
|
||||
_auth: AuthRequired,
|
||||
source_type: Optional[str] = Query(None, description="Filter by source_type: multichannel, mono, or band_extract"),
|
||||
source_type: Optional[str] = Query(
|
||||
None, description="Filter by source_type: multichannel, mono, or band_extract"
|
||||
),
|
||||
store: AudioSourceStore = Depends(get_audio_source_store),
|
||||
):
|
||||
"""List all audio sources, optionally filtered by type."""
|
||||
@@ -68,27 +113,26 @@ async def list_audio_sources(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/audio-sources", response_model=AudioSourceResponse, status_code=201, tags=["Audio Sources"])
|
||||
@router.post(
|
||||
"/api/v1/audio-sources",
|
||||
response_model=AudioSourceResponse,
|
||||
status_code=201,
|
||||
tags=["Audio Sources"],
|
||||
)
|
||||
async def create_audio_source(
|
||||
data: AudioSourceCreate,
|
||||
data: Annotated[AudioSourceCreate, Body(discriminator="source_type")],
|
||||
_auth: AuthRequired,
|
||||
store: AudioSourceStore = Depends(get_audio_source_store),
|
||||
):
|
||||
"""Create a new audio source."""
|
||||
try:
|
||||
fields = data.model_dump(exclude={"source_type", "name", "description", "tags"})
|
||||
source = store.create_source(
|
||||
name=data.name,
|
||||
source_type=data.source_type,
|
||||
device_index=data.device_index,
|
||||
is_loopback=data.is_loopback,
|
||||
audio_source_id=data.audio_source_id,
|
||||
channel=data.channel,
|
||||
description=data.description,
|
||||
audio_template_id=data.audio_template_id,
|
||||
tags=data.tags,
|
||||
band=data.band,
|
||||
freq_low=data.freq_low,
|
||||
freq_high=data.freq_high,
|
||||
**fields,
|
||||
)
|
||||
fire_entity_event("audio_source", "created", source.id)
|
||||
return _to_response(source)
|
||||
@@ -99,7 +143,9 @@ async def create_audio_source(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
|
||||
@router.get(
|
||||
"/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"]
|
||||
)
|
||||
async def get_audio_source(
|
||||
source_id: str,
|
||||
_auth: AuthRequired,
|
||||
@@ -113,29 +159,19 @@ async def get_audio_source(
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
|
||||
@router.put(
|
||||
"/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"]
|
||||
)
|
||||
async def update_audio_source(
|
||||
source_id: str,
|
||||
data: AudioSourceUpdate,
|
||||
data: Annotated[AudioSourceUpdate, Body(discriminator="source_type")],
|
||||
_auth: AuthRequired,
|
||||
store: AudioSourceStore = Depends(get_audio_source_store),
|
||||
):
|
||||
"""Update an existing audio source."""
|
||||
try:
|
||||
source = store.update_source(
|
||||
source_id=source_id,
|
||||
name=data.name,
|
||||
device_index=data.device_index,
|
||||
is_loopback=data.is_loopback,
|
||||
audio_source_id=data.audio_source_id,
|
||||
channel=data.channel,
|
||||
description=data.description,
|
||||
audio_template_id=data.audio_template_id,
|
||||
tags=data.tags,
|
||||
band=data.band,
|
||||
freq_low=data.freq_low,
|
||||
freq_high=data.freq_high,
|
||||
)
|
||||
fields = data.model_dump(exclude={"source_type"}, exclude_none=True)
|
||||
source = store.update_source(source_id=source_id, **fields)
|
||||
fire_entity_event("audio_source", "updated", source_id)
|
||||
return _to_response(source)
|
||||
except EntityNotFoundError as e:
|
||||
@@ -156,11 +192,13 @@ async def delete_audio_source(
|
||||
try:
|
||||
# Check if any CSS entities reference this audio source
|
||||
from wled_controller.storage.color_strip_source import AudioColorStripSource
|
||||
|
||||
for css in css_store.get_all_sources():
|
||||
if isinstance(css, AudioColorStripSource) and getattr(css, "audio_source_id", None) == source_id:
|
||||
raise ValueError(
|
||||
f"Cannot delete: referenced by color strip source '{css.name}'"
|
||||
)
|
||||
if (
|
||||
isinstance(css, AudioColorStripSource)
|
||||
and getattr(css, "audio_source_id", None) == source_id
|
||||
):
|
||||
raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'")
|
||||
|
||||
store.delete_source(source_id)
|
||||
fire_entity_event("audio_source", "deleted", source_id)
|
||||
@@ -187,6 +225,7 @@ async def test_audio_source_ws(
|
||||
snapshots as JSON at ~20 Hz.
|
||||
"""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
@@ -211,6 +250,7 @@ async def test_audio_source_ws(
|
||||
band_mask = None
|
||||
if resolved.freq_low is not None and resolved.freq_high is not None:
|
||||
from wled_controller.core.audio.band_filter import compute_band_mask
|
||||
|
||||
band_mask = compute_band_mask(resolved.freq_low, resolved.freq_high)
|
||||
|
||||
# Resolve template → engine_type + config
|
||||
@@ -257,15 +297,18 @@ async def test_audio_source_ws(
|
||||
# Apply band filter if present
|
||||
if band_mask is not None:
|
||||
from wled_controller.core.audio.band_filter import apply_band_filter
|
||||
|
||||
spectrum, rms = apply_band_filter(spectrum, rms, band_mask)
|
||||
|
||||
await websocket.send_json({
|
||||
"spectrum": spectrum.tolist(),
|
||||
"rms": round(rms, 4),
|
||||
"peak": round(analysis.peak, 4),
|
||||
"beat": analysis.beat,
|
||||
"beat_intensity": round(analysis.beat_intensity, 4),
|
||||
})
|
||||
await websocket.send_json(
|
||||
{
|
||||
"spectrum": spectrum.tolist(),
|
||||
"rms": round(rms, 4),
|
||||
"peak": round(analysis.peak, 4),
|
||||
"beat": analysis.beat,
|
||||
"beat_intensity": round(analysis.beat_intensity, 4),
|
||||
}
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
except WebSocketDisconnect:
|
||||
|
||||
Reference in New Issue
Block a user