feat: new value source types (HA entity, gradient map, strip extract) + UI fixes
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:
2026-03-29 20:38:22 +03:00
parent ea812bb4d5
commit 384362ccf1
61 changed files with 5367 additions and 1620 deletions
@@ -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:
@@ -4,9 +4,10 @@ import asyncio
import json as _json
import time as _time
import uuid as _uuid
from typing import Annotated
import numpy as np
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
@@ -20,13 +21,30 @@ from wled_controller.api.dependencies import (
get_template_store,
)
from wled_controller.api.schemas.color_strip_sources import (
ApiInputCSSResponse,
AudioCSSResponse,
CandlelightCSSResponse,
ColorCycleCSSResponse,
ColorPushRequest,
ColorStop as ColorStopSchema,
ColorStripSourceCreate,
ColorStripSourceListResponse,
ColorStripSourceResponse,
ColorStripSourceUpdate,
CompositeCSSResponse,
CSSCalibrationTestRequest,
DaylightCSSResponse,
EffectCSSResponse,
GradientCSSResponse,
KeyColorsCSSResponse,
MappedCSSResponse,
NotificationCSSResponse,
NotifyRequest,
PictureAdvancedCSSResponse,
PictureCSSResponse,
ProcessedCSSResponse,
StaticCSSResponse,
WeatherCSSResponse,
)
from wled_controller.api.schemas.devices import (
Calibration as CalibrationSchema,
@@ -34,15 +52,27 @@ from wled_controller.api.schemas.devices import (
)
from wled_controller.core.capture.calibration import (
calibration_from_dict,
calibration_to_dict,
)
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.color_strip_source import (
AdvancedPictureColorStripSource,
ApiInputColorStripSource,
AudioColorStripSource,
CandlelightColorStripSource,
ColorCycleColorStripSource,
CompositeColorStripSource,
DaylightColorStripSource,
EffectColorStripSource,
GradientColorStripSource,
KeyColorsColorStripSource,
MappedColorStripSource,
NotificationColorStripSource,
PictureColorStripSource,
ProcessedColorStripSource,
StaticColorStripSource,
WeatherColorStripSource,
)
from wled_controller.storage import DeviceStore
from wled_controller.storage.color_strip_store import ColorStripStore
@@ -61,50 +91,179 @@ logger = get_logger(__name__)
router = APIRouter()
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
"""Convert a ColorStripSource to a ColorStripSourceResponse.
Uses the source's to_dict() for type-specific fields, then applies
schema conversions for calibration and gradient stops.
"""
from wled_controller.api.schemas.color_strip_sources import ColorStop as ColorStopSchema
d = source.to_dict()
# Convert calibration dict → schema object
calibration = None
raw_cal = d.pop("calibration", None)
if raw_cal and isinstance(raw_cal, dict):
calibration = CalibrationSchema(**raw_cal)
# Convert stop dicts → schema objects
raw_stops = d.pop("stops", None)
stops = None
if raw_stops is not None:
try:
stops = [ColorStopSchema(**s) for s in raw_stops]
except Exception:
stops = None
# Remove serialized timestamp strings — use actual datetime objects
d.pop("created_at", None)
d.pop("updated_at", None)
# Filter to only keys accepted by the schema (to_dict may include extra
# fields like 'fps' that aren't in the response model)
valid_fields = ColorStripSourceResponse.model_fields
filtered = {k: v for k, v in d.items() if k in valid_fields}
return ColorStripSourceResponse(
**filtered,
calibration=calibration,
stops=stops,
def _common_response_kwargs(source, overlay_active: bool = False) -> dict:
"""Shared response fields from any ColorStripSource."""
return dict(
id=source.id,
name=source.name,
description=source.description,
led_count=getattr(source, "led_count", 0),
overlay_active=overlay_active,
clock_id=source.clock_id,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
)
def _calibration_schema(source) -> CalibrationSchema | None:
"""Convert a source's calibration to a schema object, or None."""
cal = getattr(source, "calibration", None)
if cal is None:
return None
try:
return CalibrationSchema(**calibration_to_dict(cal))
except Exception:
return None
def _stops_schema(source) -> list[ColorStopSchema] | None:
"""Convert a source's stops list to schema objects, or None."""
raw = getattr(source, "stops", None)
if raw is None:
return None
try:
return [ColorStopSchema(**dict(s)) for s in raw]
except Exception:
return None
# Maps storage class → response builder lambda.
_RESPONSE_MAP: dict = {
PictureColorStripSource: lambda s, kw: PictureCSSResponse(
**kw,
picture_source_id=s.picture_source_id,
smoothing=s.smoothing.to_dict(),
interpolation_mode=s.interpolation_mode,
calibration=_calibration_schema(s),
),
AdvancedPictureColorStripSource: lambda s, kw: PictureAdvancedCSSResponse(
**kw,
smoothing=s.smoothing.to_dict(),
interpolation_mode=s.interpolation_mode,
calibration=_calibration_schema(s),
),
StaticColorStripSource: lambda s, kw: StaticCSSResponse(
**kw,
color=s.color.to_dict(),
animation=s.animation,
),
GradientColorStripSource: lambda s, kw: GradientCSSResponse(
**kw,
stops=_stops_schema(s),
animation=s.animation,
easing=s.easing,
gradient_id=s.gradient_id,
),
ColorCycleColorStripSource: lambda s, kw: ColorCycleCSSResponse(
**kw,
colors=[list(c) for c in s.colors],
),
EffectColorStripSource: lambda s, kw: EffectCSSResponse(
**kw,
effect_type=s.effect_type,
palette=s.palette,
gradient_id=s.gradient_id,
color=s.color.to_dict(),
intensity=s.intensity.to_dict(),
scale=s.scale.to_dict(),
mirror=s.mirror,
custom_palette=s.custom_palette,
),
CompositeColorStripSource: lambda s, kw: CompositeCSSResponse(
**kw,
layers=[dict(layer) for layer in s.layers],
),
MappedColorStripSource: lambda s, kw: MappedCSSResponse(
**kw,
zones=[dict(z) for z in s.zones],
),
AudioColorStripSource: lambda s, kw: AudioCSSResponse(
**kw,
visualization_mode=s.visualization_mode,
audio_source_id=s.audio_source_id,
sensitivity=s.sensitivity.to_dict(),
smoothing=s.smoothing.to_dict(),
palette=s.palette,
gradient_id=s.gradient_id,
color=s.color.to_dict(),
color_peak=s.color_peak.to_dict(),
mirror=s.mirror,
),
ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse(
**kw,
fallback_color=s.fallback_color.to_dict(),
timeout=s.timeout.to_dict(),
interpolation=s.interpolation,
),
NotificationColorStripSource: lambda s, kw: NotificationCSSResponse(
**kw,
notification_effect=s.notification_effect,
duration_ms=s.duration_ms.to_dict(),
default_color=s.default_color.to_dict(),
app_colors=dict(s.app_colors),
app_filter_mode=s.app_filter_mode,
app_filter_list=list(s.app_filter_list),
os_listener=s.os_listener,
sound_asset_id=s.sound_asset_id,
sound_volume=s.sound_volume.to_dict(),
app_sounds=dict(s.app_sounds),
),
DaylightColorStripSource: lambda s, kw: DaylightCSSResponse(
**kw,
speed=s.speed.to_dict(),
use_real_time=s.use_real_time,
latitude=s.latitude,
longitude=s.longitude,
),
CandlelightColorStripSource: lambda s, kw: CandlelightCSSResponse(
**kw,
color=s.color.to_dict(),
intensity=s.intensity.to_dict(),
num_candles=s.num_candles,
speed=s.speed.to_dict(),
wind_strength=s.wind_strength.to_dict(),
candle_type=s.candle_type,
),
ProcessedColorStripSource: lambda s, kw: ProcessedCSSResponse(
**kw,
input_source_id=s.input_source_id,
processing_template_id=s.processing_template_id,
),
WeatherColorStripSource: lambda s, kw: WeatherCSSResponse(
**kw,
weather_source_id=s.weather_source_id,
speed=s.speed.to_dict(),
temperature_influence=s.temperature_influence.to_dict(),
),
KeyColorsColorStripSource: lambda s, kw: KeyColorsCSSResponse(
**kw,
picture_source_id=s.picture_source_id,
rectangles=[r.to_dict() for r in s.rectangles],
interpolation_mode=s.interpolation_mode,
smoothing=s.smoothing.to_dict(),
brightness=s.brightness.to_dict(),
),
}
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
"""Convert a ColorStripSource to the matching per-type response schema."""
kw = _common_response_kwargs(source, overlay_active)
builder = _RESPONSE_MAP.get(type(source))
if builder is None:
# Fallback: use to_dict() and build a PictureCSSResponse
logger.warning("No response builder for %s, falling back", type(source).__name__)
return PictureCSSResponse(
**kw,
picture_source_id="",
smoothing=0.3,
interpolation_mode="average",
calibration=None,
)
return builder(source, kw)
def _resolve_display_index(
picture_source_id: str, picture_source_store: PictureSourceStore, depth: int = 0
) -> int:
@@ -150,22 +309,37 @@ def _extract_css_kwargs(data) -> dict:
Converts nested Pydantic models (calibration, stops, layers, zones,
animation) to plain dicts/lists that the store expects.
"""
kwargs = data.model_dump(
exclude_unset=False, exclude={"calibration", "stops", "layers", "zones", "animation"}
)
# Remove fields that don't map to store kwargs
kwargs.pop("source_type", None)
# Exclude nested models that need special conversion
exclude_fields = {"source_type"}
for nested in ("calibration", "stops", "layers", "zones", "animation"):
if hasattr(data, nested):
exclude_fields.add(nested)
kwargs = data.model_dump(exclude_unset=False, exclude=exclude_fields)
# Convert nested Pydantic models → plain dicts for the store
if hasattr(data, "calibration"):
cal = getattr(data, "calibration", None)
if cal is not None:
kwargs["calibration"] = calibration_from_dict(cal.model_dump())
else:
kwargs["calibration"] = None
if hasattr(data, "stops"):
stops = getattr(data, "stops", None)
kwargs["stops"] = [s.model_dump() for s in stops] if stops is not None else None
if hasattr(data, "layers"):
layers = getattr(data, "layers", None)
kwargs["layers"] = [layer.model_dump() for layer in layers] if layers is not None else None
if hasattr(data, "zones"):
zones = getattr(data, "zones", None)
kwargs["zones"] = [z.model_dump() for z in zones] if zones is not None else None
if hasattr(data, "animation"):
anim = getattr(data, "animation", None)
kwargs["animation"] = anim.model_dump() if anim else None
if data.calibration is not None:
kwargs["calibration"] = calibration_from_dict(data.calibration.model_dump())
else:
kwargs["calibration"] = None
kwargs["stops"] = [s.model_dump() for s in data.stops] if data.stops is not None else None
kwargs["layers"] = (
[layer.model_dump() for layer in data.layers] if data.layers is not None else None
)
kwargs["zones"] = [z.model_dump() for z in data.zones] if data.zones is not None else None
kwargs["animation"] = data.animation.model_dump() if data.animation else None
return kwargs
@@ -176,7 +350,7 @@ def _extract_css_kwargs(data) -> dict:
status_code=201,
)
async def create_color_strip_source(
data: ColorStripSourceCreate,
data: Annotated[ColorStripSourceCreate, Body(discriminator="source_type")],
_auth: AuthRequired,
store: ColorStripStore = Depends(get_color_strip_store),
):
@@ -223,7 +397,7 @@ async def get_color_strip_source(
)
async def update_color_strip_source(
source_id: str,
data: ColorStripSourceUpdate,
data: Annotated[ColorStripSourceUpdate, Body(discriminator="source_type")],
_auth: AuthRequired,
store: ColorStripStore = Depends(get_color_strip_store),
manager: ProcessorManager = Depends(get_processor_manager),
@@ -1,8 +1,9 @@
"""Output target routes: CRUD endpoints and batch state/metrics queries."""
import asyncio
from typing import Annotated
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, Body, HTTPException, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
@@ -12,6 +13,9 @@ from wled_controller.api.dependencies import (
get_processor_manager,
)
from wled_controller.api.schemas.output_targets import (
HALightMappingSchema,
HALightOutputTargetResponse,
LedOutputTargetResponse,
OutputTargetCreate,
OutputTargetListResponse,
OutputTargetResponse,
@@ -25,7 +29,6 @@ from wled_controller.storage.ha_light_output_target import (
HALightMapping,
HALightOutputTarget,
)
from wled_controller.api.schemas.output_targets import HALightMappingSchema
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
@@ -35,58 +38,68 @@ logger = get_logger(__name__)
router = APIRouter()
def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse:
"""Convert a WledOutputTarget to LedOutputTargetResponse."""
return LedOutputTargetResponse(
id=target.id,
name=target.name,
device_id=target.device_id,
color_strip_source_id=target.color_strip_source_id,
brightness=target.brightness.to_dict(),
fps=target.fps.to_dict(),
keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval,
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
adaptive_fps=target.adaptive_fps,
protocol=target.protocol,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
def _ha_light_target_to_response(
target: HALightOutputTarget,
) -> HALightOutputTargetResponse:
"""Convert an HALightOutputTarget to HALightOutputTargetResponse."""
return HALightOutputTargetResponse(
id=target.id,
name=target.name,
ha_source_id=target.ha_source_id,
color_strip_source_id=target.color_strip_source_id,
brightness=target.brightness.to_dict(),
ha_light_mappings=[
HALightMappingSchema(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale.to_dict(),
)
for m in target.light_mappings
],
update_rate=target.update_rate.to_dict(),
transition=target.transition.to_dict(),
color_tolerance=target.color_tolerance.to_dict(),
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
def _target_to_response(target) -> OutputTargetResponse:
"""Convert an OutputTarget to OutputTargetResponse."""
"""Convert any OutputTarget to the appropriate typed response."""
if isinstance(target, WledOutputTarget):
return OutputTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
device_id=target.device_id,
color_strip_source_id=target.color_strip_source_id,
brightness=target.brightness.to_dict(),
fps=target.fps.to_dict(),
keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval,
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
adaptive_fps=target.adaptive_fps,
protocol=target.protocol,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
return _led_target_to_response(target)
elif isinstance(target, HALightOutputTarget):
return OutputTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
ha_source_id=target.ha_source_id,
color_strip_source_id=target.color_strip_source_id,
brightness=target.brightness.to_dict(),
ha_light_mappings=[
HALightMappingSchema(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale.to_dict(),
)
for m in target.light_mappings
],
update_rate=target.update_rate.to_dict(),
transition=target.transition.to_dict(),
color_tolerance=target.color_tolerance.to_dict(),
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
return _ha_light_target_to_response(target)
else:
return OutputTargetResponse(
# Fallback for unknown types — use LED response with defaults
return LedOutputTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
description=target.description,
tags=target.tags,
created_at=target.created_at,
@@ -101,7 +114,7 @@ def _target_to_response(target) -> OutputTargetResponse:
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
)
async def create_target(
data: OutputTargetCreate,
data: Annotated[OutputTargetCreate, Body(discriminator="target_type")],
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
device_store: DeviceStore = Depends(get_device_store),
@@ -110,12 +123,14 @@ async def create_target(
"""Create a new output target."""
try:
# Validate device exists if provided
if data.device_id:
device_id = getattr(data, "device_id", "")
if device_id:
try:
device_store.get_device(data.device_id)
device_store.get_device(device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
ha_mappings = (
[
HALightMapping(
@@ -124,9 +139,9 @@ async def create_target(
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
)
for m in data.ha_light_mappings
for m in ha_light_mappings_raw
]
if data.ha_light_mappings
if ha_light_mappings_raw
else None
)
@@ -134,22 +149,22 @@ async def create_target(
target = target_store.create_target(
name=data.name,
target_type=data.target_type,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
brightness=data.brightness,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
device_id=device_id,
color_strip_source_id=getattr(data, "color_strip_source_id", ""),
brightness=getattr(data, "brightness", 1.0),
fps=getattr(data, "fps", 30),
keepalive_interval=getattr(data, "keepalive_interval", 1.0),
state_check_interval=getattr(data, "state_check_interval", 30),
min_brightness_threshold=getattr(data, "min_brightness_threshold", 0),
adaptive_fps=getattr(data, "adaptive_fps", False),
protocol=getattr(data, "protocol", "ddp"),
description=data.description,
tags=data.tags,
ha_source_id=data.ha_source_id,
ha_source_id=getattr(data, "ha_source_id", ""),
ha_light_mappings=ha_mappings,
update_rate=data.update_rate,
transition=data.transition,
color_tolerance=data.color_tolerance,
update_rate=getattr(data, "update_rate", 2.0),
transition=getattr(data, "transition", 0.5),
color_tolerance=getattr(data, "color_tolerance", 5),
)
# Register in processor manager
@@ -223,7 +238,7 @@ async def get_target(
)
async def update_target(
target_id: str,
data: OutputTargetUpdate,
data: Annotated[OutputTargetUpdate, Body(discriminator="target_type")],
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
device_store: DeviceStore = Depends(get_device_store),
@@ -232,15 +247,17 @@ async def update_target(
"""Update a output target."""
try:
# Validate device exists if changing
if data.device_id is not None and data.device_id:
device_id = getattr(data, "device_id", None)
if device_id is not None and device_id:
try:
device_store.get_device(data.device_id)
device_store.get_device(device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
# Build HA light mappings if provided
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
ha_mappings = None
if data.ha_light_mappings is not None:
if ha_light_mappings_raw is not None:
ha_mappings = [
HALightMapping(
entity_id=m.entity_id,
@@ -248,57 +265,68 @@ async def update_target(
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
)
for m in data.ha_light_mappings
for m in ha_light_mappings_raw
]
# Update in store
target = target_store.update_target(
target_id=target_id,
name=data.name,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
brightness=data.brightness,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
device_id=device_id,
color_strip_source_id=getattr(data, "color_strip_source_id", None),
brightness=getattr(data, "brightness", None),
fps=getattr(data, "fps", None),
keepalive_interval=getattr(data, "keepalive_interval", None),
state_check_interval=getattr(data, "state_check_interval", None),
min_brightness_threshold=getattr(data, "min_brightness_threshold", None),
adaptive_fps=getattr(data, "adaptive_fps", None),
protocol=getattr(data, "protocol", None),
description=data.description,
tags=data.tags,
ha_source_id=data.ha_source_id,
ha_source_id=getattr(data, "ha_source_id", None),
ha_light_mappings=ha_mappings,
update_rate=data.update_rate,
transition=data.transition,
color_tolerance=data.color_tolerance,
update_rate=getattr(data, "update_rate", None),
transition=getattr(data, "transition", None),
color_tolerance=getattr(data, "color_tolerance", None),
)
# Sync processor manager (run in thread — css release/acquire can block)
color_strip_source_id = getattr(data, "color_strip_source_id", None)
fps = getattr(data, "fps", None)
keepalive_interval = getattr(data, "keepalive_interval", None)
state_check_interval = getattr(data, "state_check_interval", None)
min_brightness_threshold = getattr(data, "min_brightness_threshold", None)
adaptive_fps = getattr(data, "adaptive_fps", None)
update_rate = getattr(data, "update_rate", None)
transition = getattr(data, "transition", None)
color_tolerance = getattr(data, "color_tolerance", None)
brightness = getattr(data, "brightness", None)
try:
await asyncio.to_thread(
target.sync_with_manager,
manager,
settings_changed=(
data.fps is not None
or data.keepalive_interval is not None
or data.state_check_interval is not None
or data.min_brightness_threshold is not None
or data.adaptive_fps is not None
or data.update_rate is not None
or data.transition is not None
or data.color_tolerance is not None
or data.ha_light_mappings is not None
or data.brightness is not None
fps is not None
or keepalive_interval is not None
or state_check_interval is not None
or min_brightness_threshold is not None
or adaptive_fps is not None
or update_rate is not None
or transition is not None
or color_tolerance is not None
or ha_light_mappings_raw is not None
or brightness is not None
),
css_changed=data.color_strip_source_id is not None,
brightness_changed=data.brightness is not None,
css_changed=color_strip_source_id is not None,
brightness_changed=brightness is not None,
)
except ValueError as e:
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
pass
# Device change requires async stop -> swap -> start cycle
if data.device_id is not None:
if device_id is not None:
try:
await manager.update_target_device(target_id, target.device_id)
except ValueError as e:
@@ -2,10 +2,11 @@
import asyncio
import time
from typing import Annotated
import httpx
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Body, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
from wled_controller.api.auth import AuthRequired
@@ -29,6 +30,10 @@ from wled_controller.api.schemas.picture_sources import (
PictureSourceResponse,
PictureSourceTestRequest,
PictureSourceUpdate,
ProcessedPictureSourceResponse,
RawPictureSourceResponse,
StaticImagePictureSourceResponse,
VideoPictureSourceResponse,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
@@ -36,7 +41,12 @@ from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource, VideoCaptureSource
from wled_controller.storage.picture_source import (
ProcessedPictureSource,
ScreenCapturePictureSource,
StaticImagePictureSource,
VideoCaptureSource,
)
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
@@ -45,34 +55,67 @@ logger = get_logger(__name__)
router = APIRouter()
def _stream_to_response(s) -> PictureSourceResponse:
"""Convert a PictureSource to its API response."""
return PictureSourceResponse(
_RESPONSE_MAP = {
ScreenCapturePictureSource: lambda s: RawPictureSourceResponse(
id=s.id,
name=s.name,
stream_type=s.stream_type,
display_index=getattr(s, "display_index", None),
capture_template_id=getattr(s, "capture_template_id", None),
target_fps=getattr(s, "target_fps", None),
source_stream_id=getattr(s, "source_stream_id", None),
postprocessing_template_id=getattr(s, "postprocessing_template_id", None),
image_asset_id=getattr(s, "image_asset_id", None),
created_at=s.created_at,
updated_at=s.updated_at,
description=s.description,
tags=s.tags,
# Video fields
video_asset_id=getattr(s, "video_asset_id", None),
loop=getattr(s, "loop", None),
playback_speed=getattr(s, "playback_speed", None),
start_time=getattr(s, "start_time", None),
end_time=getattr(s, "end_time", None),
resolution_limit=getattr(s, "resolution_limit", None),
clock_id=getattr(s, "clock_id", None),
)
created_at=s.created_at,
updated_at=s.updated_at,
display_index=s.display_index,
capture_template_id=s.capture_template_id,
target_fps=s.target_fps,
),
ProcessedPictureSource: lambda s: ProcessedPictureSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
source_stream_id=s.source_stream_id,
postprocessing_template_id=s.postprocessing_template_id,
),
StaticImagePictureSource: lambda s: StaticImagePictureSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
image_asset_id=s.image_asset_id,
),
VideoCaptureSource: lambda s: VideoPictureSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
video_asset_id=s.video_asset_id,
loop=s.loop,
playback_speed=s.playback_speed,
start_time=s.start_time,
end_time=s.end_time,
resolution_limit=s.resolution_limit,
clock_id=s.clock_id,
target_fps=s.target_fps,
),
}
@router.get("/api/v1/picture-sources", response_model=PictureSourceListResponse, tags=["Picture Sources"])
def _stream_to_response(s) -> PictureSourceResponse:
"""Convert a PictureSource storage model to the matching response schema."""
builder = _RESPONSE_MAP.get(type(s))
if builder is None:
raise ValueError(f"Unknown picture source type: {type(s).__name__}")
return builder(s)
@router.get(
"/api/v1/picture-sources", response_model=PictureSourceListResponse, tags=["Picture Sources"]
)
async def list_picture_sources(
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
@@ -87,7 +130,11 @@ async def list_picture_sources(
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/picture-sources/validate-image", response_model=ImageValidateResponse, tags=["Picture Sources"])
@router.post(
"/api/v1/picture-sources/validate-image",
response_model=ImageValidateResponse,
tags=["Picture Sources"],
)
async def validate_image(
data: ImageValidateRequest,
_auth: AuthRequired,
@@ -120,6 +167,7 @@ async def validate_image(
load_image_file,
thumbnail as make_thumbnail,
)
if isinstance(src, bytes):
image = load_image_bytes(src)
else:
@@ -131,12 +179,12 @@ async def validate_image(
width, height, preview = await asyncio.to_thread(_process_image, img_bytes)
return ImageValidateResponse(
valid=True, width=width, height=height, preview=preview
)
return ImageValidateResponse(valid=True, width=width, height=height, preview=preview)
except httpx.HTTPStatusError as e:
return ImageValidateResponse(valid=False, error=f"HTTP {e.response.status_code}: {e.response.reason_phrase}")
return ImageValidateResponse(
valid=False, error=f"HTTP {e.response.status_code}: {e.response.reason_phrase}"
)
except httpx.RequestError as e:
return ImageValidateResponse(valid=False, error=f"Request failed: {e}")
except Exception as e:
@@ -166,7 +214,12 @@ async def get_full_image(
img_bytes = path
def _encode_full(src):
from wled_controller.utils.image_codec import encode_jpeg, load_image_bytes, load_image_file
from wled_controller.utils.image_codec import (
encode_jpeg,
load_image_bytes,
load_image_file,
)
if isinstance(src, bytes):
image = load_image_bytes(src)
else:
@@ -182,9 +235,14 @@ async def get_full_image(
raise HTTPException(status_code=400, detail=str(e))
@router.post("/api/v1/picture-sources", response_model=PictureSourceResponse, tags=["Picture Sources"], status_code=201)
@router.post(
"/api/v1/picture-sources",
response_model=PictureSourceResponse,
tags=["Picture Sources"],
status_code=201,
)
async def create_picture_source(
data: PictureSourceCreate,
data: Annotated[PictureSourceCreate, Body(discriminator="stream_type")],
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_template_store),
@@ -211,25 +269,13 @@ async def create_picture_source(
detail=f"Postprocessing template not found: {data.postprocessing_template_id}",
)
fields = data.model_dump(exclude={"stream_type", "name", "description", "tags"})
stream = store.create_stream(
name=data.name,
stream_type=data.stream_type,
display_index=data.display_index,
capture_template_id=data.capture_template_id,
target_fps=data.target_fps,
source_stream_id=data.source_stream_id,
postprocessing_template_id=data.postprocessing_template_id,
image_asset_id=data.image_asset_id,
description=data.description,
tags=data.tags,
# Video fields
video_asset_id=data.video_asset_id,
loop=data.loop,
playback_speed=data.playback_speed,
start_time=data.start_time,
end_time=data.end_time,
resolution_limit=data.resolution_limit,
clock_id=data.clock_id,
**fields,
)
fire_entity_event("picture_source", "created", stream.id)
return _stream_to_response(stream)
@@ -245,7 +291,11 @@ async def create_picture_source(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"])
@router.get(
"/api/v1/picture-sources/{stream_id}",
response_model=PictureSourceResponse,
tags=["Picture Sources"],
)
async def get_picture_source(
stream_id: str,
_auth: AuthRequired,
@@ -259,35 +309,21 @@ async def get_picture_source(
raise HTTPException(status_code=404, detail=f"Picture source {stream_id} not found")
@router.put("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"])
@router.put(
"/api/v1/picture-sources/{stream_id}",
response_model=PictureSourceResponse,
tags=["Picture Sources"],
)
async def update_picture_source(
stream_id: str,
data: PictureSourceUpdate,
data: Annotated[PictureSourceUpdate, Body(discriminator="stream_type")],
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
):
"""Update a picture source."""
try:
stream = store.update_stream(
stream_id=stream_id,
name=data.name,
display_index=data.display_index,
capture_template_id=data.capture_template_id,
target_fps=data.target_fps,
source_stream_id=data.source_stream_id,
postprocessing_template_id=data.postprocessing_template_id,
image_asset_id=data.image_asset_id,
description=data.description,
tags=data.tags,
# Video fields
video_asset_id=data.video_asset_id,
loop=data.loop,
playback_speed=data.playback_speed,
start_time=data.start_time,
end_time=data.end_time,
resolution_limit=data.resolution_limit,
clock_id=data.clock_id,
)
fields = data.model_dump(exclude={"stream_type"}, exclude_none=True)
stream = store.update_stream(stream_id=stream_id, **fields)
fire_entity_event("picture_source", "updated", stream_id)
return _stream_to_response(stream)
except EntityNotFoundError as e:
@@ -316,7 +352,7 @@ async def delete_picture_source(
raise HTTPException(
status_code=409,
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. "
"Please reassign those targets before deleting.",
"Please reassign those targets before deleting.",
)
store.delete_stream(stream_id)
fire_entity_event("picture_source", "deleted", stream_id)
@@ -350,8 +386,11 @@ async def get_video_thumbnail(
# Resolve video asset to file path
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
asset_store = _get_asset_store()
video_path = asset_store.get_file_path(source.video_asset_id) if source.video_asset_id else None
video_path = (
asset_store.get_file_path(source.video_asset_id) if source.video_asset_id else None
)
if not video_path:
raise HTTPException(status_code=400, detail="Video asset not found or missing file")
@@ -375,7 +414,11 @@ async def get_video_thumbnail(
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"])
@router.post(
"/api/v1/picture-sources/{stream_id}/test",
response_model=TemplateTestResponse,
tags=["Picture Sources"],
)
async def test_picture_source(
stream_id: str,
test_request: PictureSourceTestRequest,
@@ -410,7 +453,11 @@ async def test_picture_source(
from wled_controller.utils.image_codec import load_image_file
asset_store = _get_asset_store()
image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
image_path = (
asset_store.get_file_path(raw_stream.image_asset_id)
if raw_stream.image_asset_id
else None
)
if not image_path:
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
@@ -460,7 +507,9 @@ async def test_picture_source(
frame_count = 1
last_frame = screen_capture
else:
logger.info(f"Starting {test_request.capture_duration}s stream test for {stream_id}")
logger.info(
f"Starting {test_request.capture_duration}s stream test for {stream_id}"
)
end_time = start_time + test_request.capture_duration
while time.perf_counter() < end_time:
capture_start = time.perf_counter()
@@ -482,7 +531,10 @@ async def test_picture_source(
image = last_frame.image
# Create thumbnail + encode (CPU-bound — run in thread)
from wled_controller.utils.image_codec import encode_jpeg_data_uri, thumbnail as make_thumbnail
from wled_controller.utils.image_codec import (
encode_jpeg_data_uri,
thumbnail as make_thumbnail,
)
pp_template_ids = chain["postprocessing_template_ids"]
flat_filters = None
@@ -491,13 +543,16 @@ async def test_picture_source(
pp_template = pp_store.get_template(pp_template_ids[0])
flat_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
except ValueError:
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
logger.warning(
f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview"
)
def _create_thumbnails_and_encode(img, filters):
thumb = make_thumbnail(img, 640)
if filters:
pool = ImagePool()
def apply_filters(arr):
for fi in filters:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
@@ -505,6 +560,7 @@ async def test_picture_source(
if result is not None:
arr = result
return arr
thumb = apply_filters(thumb)
img = apply_filters(img)
@@ -513,8 +569,8 @@ async def test_picture_source(
th, tw = thumb.shape[:2]
return tw, th, thumb_uri, full_uri
thumbnail_width, thumbnail_height, thumbnail_data_uri, full_data_uri = await asyncio.to_thread(
_create_thumbnails_and_encode, image, flat_filters
thumbnail_width, thumbnail_height, thumbnail_data_uri, full_data_uri = (
await asyncio.to_thread(_create_thumbnails_and_encode, image, flat_filters)
)
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
@@ -610,7 +666,11 @@ async def test_picture_source_ws(
from wled_controller.api.dependencies import get_asset_store as _get_asset_store2
asset_store = _get_asset_store2()
video_path = asset_store.get_file_path(raw_stream.video_asset_id) if raw_stream.video_asset_id else None
video_path = (
asset_store.get_file_path(raw_stream.video_asset_id)
if raw_stream.video_asset_id
else None
)
if not video_path:
await websocket.close(code=4004, reason="Video asset not found or missing file")
return
@@ -631,6 +691,7 @@ async def test_picture_source_ws(
def _encode_video_frame(image, pw):
"""Encode numpy RGB image as JPEG base64 data URI."""
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
if pw:
image = resize_down(image, pw)
h, w = image.shape[:2]
@@ -639,6 +700,7 @@ async def test_picture_source_ws(
try:
await asyncio.get_event_loop().run_in_executor(None, video_stream.start)
import time as _time
fps = min(raw_stream.target_fps or 30, 30)
frame_time = 1.0 / fps
end_at = _time.monotonic() + duration
@@ -650,30 +712,42 @@ async def test_picture_source_ws(
last_frame = frame
frame_count += 1
thumb, w, h = await asyncio.get_event_loop().run_in_executor(
None, _encode_video_frame, frame.image, preview_width or None,
None,
_encode_video_frame,
frame.image,
preview_width or None,
)
elapsed = duration - (end_at - _time.monotonic())
await websocket.send_json({
"type": "frame",
"thumbnail": thumb,
"width": w, "height": h,
"elapsed": round(elapsed, 1),
"frame_count": frame_count,
})
await websocket.send_json(
{
"type": "frame",
"thumbnail": thumb,
"width": w,
"height": h,
"elapsed": round(elapsed, 1),
"frame_count": frame_count,
}
)
await asyncio.sleep(frame_time)
# Send final result
if last_frame is not None:
full_img, fw, fh = await asyncio.get_event_loop().run_in_executor(
None, _encode_video_frame, last_frame.image, None,
None,
_encode_video_frame,
last_frame.image,
None,
)
await websocket.send_json(
{
"type": "result",
"full_image": full_img,
"width": fw,
"height": fh,
"total_frames": frame_count,
"duration": duration,
"avg_fps": round(frame_count / max(duration, 0.001), 1),
}
)
await websocket.send_json({
"type": "result",
"full_image": full_img,
"width": fw, "height": fh,
"total_frames": frame_count,
"duration": duration,
"avg_fps": round(frame_count / max(duration, 0.001), 1),
})
except WebSocketDisconnect:
logger.debug("Video source test WebSocket disconnected for %s", stream_id)
pass
@@ -701,7 +775,9 @@ async def test_picture_source_ws(
return
if capture_template.engine_type not in EngineRegistry.get_available_engines():
await websocket.close(code=4003, reason=f"Engine '{capture_template.engine_type}' not available")
await websocket.close(
code=4003, reason=f"Engine '{capture_template.engine_type}' not available"
)
return
# Resolve postprocessing filters (if any)
@@ -731,7 +807,9 @@ async def test_picture_source_ws(
try:
await stream_capture_test(
websocket, engine_factory, duration,
websocket,
engine_factory,
duration,
pp_filters=pp_filters,
preview_width=preview_width or None,
)
@@ -1,9 +1,9 @@
"""Value source routes: CRUD for value sources."""
import asyncio
from typing import Optional
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
@@ -13,12 +13,37 @@ from wled_controller.api.dependencies import (
get_value_source_store,
)
from wled_controller.api.schemas.value_sources import (
AdaptiveSceneValueSourceResponse,
AdaptiveTimeColorValueSourceResponse,
AdaptiveTimeValueSourceResponse,
AnimatedColorValueSourceResponse,
AnimatedValueSourceResponse,
AudioValueSourceResponse,
CSSExtractValueSourceResponse,
DaylightValueSourceResponse,
GradientMapValueSourceResponse,
HAEntityValueSourceResponse,
StaticColorValueSourceResponse,
StaticValueSourceResponse,
ValueSourceCreate,
ValueSourceListResponse,
ValueSourceResponse,
ValueSourceUpdate,
)
from wled_controller.storage.value_source import ValueSource
from wled_controller.storage.value_source import (
AdaptiveTimeColorValueSource,
AdaptiveValueSource,
AnimatedColorValueSource,
AnimatedValueSource,
AudioValueSource,
CSSExtractValueSource,
DaylightValueSource,
GradientMapValueSource,
HAEntityValueSource,
StaticColorValueSource,
StaticValueSource,
ValueSource,
)
from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.core.processing.processor_manager import ProcessorManager
@@ -29,40 +54,178 @@ logger = get_logger(__name__)
router = APIRouter()
# Maps storage class to the response builder for that type.
_RESPONSE_MAP = {
StaticValueSource: lambda s: StaticValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
value=s.value,
),
AnimatedValueSource: lambda s: AnimatedValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
waveform=s.waveform,
speed=s.speed,
min_value=s.min_value,
max_value=s.max_value,
),
AudioValueSource: lambda s: AudioValueSourceResponse(
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,
mode=s.mode,
sensitivity=s.sensitivity,
smoothing=s.smoothing,
min_value=s.min_value,
max_value=s.max_value,
auto_gain=s.auto_gain,
),
DaylightValueSource: lambda s: DaylightValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
speed=s.speed,
use_real_time=s.use_real_time,
latitude=s.latitude,
min_value=s.min_value,
max_value=s.max_value,
),
StaticColorValueSource: lambda s: StaticColorValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
color=list(s.color),
),
AnimatedColorValueSource: lambda s: AnimatedColorValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
colors=[list(c) for c in s.colors],
speed=s.speed,
easing=s.easing,
),
AdaptiveTimeColorValueSource: lambda s: AdaptiveTimeColorValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
schedule=s.schedule,
),
HAEntityValueSource: lambda s: HAEntityValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
ha_source_id=s.ha_source_id,
entity_id=s.entity_id,
attribute=s.attribute,
min_ha_value=s.min_ha_value,
max_ha_value=s.max_ha_value,
smoothing=s.smoothing,
),
GradientMapValueSource: lambda s: GradientMapValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
value_source_id=s.value_source_id,
gradient_id=s.gradient_id,
easing=s.easing,
),
CSSExtractValueSource: lambda s: CSSExtractValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
color_strip_source_id=s.color_strip_source_id,
led_start=s.led_start,
led_end=s.led_end,
),
}
def _to_response(source: ValueSource) -> ValueSourceResponse:
"""Convert a ValueSource to a ValueSourceResponse."""
d = source.to_dict()
return ValueSourceResponse(
id=d["id"],
name=d["name"],
source_type=d["source_type"],
value=d.get("value"),
waveform=d.get("waveform"),
speed=d.get("speed"),
min_value=d.get("min_value"),
max_value=d.get("max_value"),
audio_source_id=d.get("audio_source_id"),
mode=d.get("mode"),
sensitivity=d.get("sensitivity"),
smoothing=d.get("smoothing"),
auto_gain=d.get("auto_gain"),
schedule=d.get("schedule"),
picture_source_id=d.get("picture_source_id"),
scene_behavior=d.get("scene_behavior"),
use_real_time=d.get("use_real_time"),
latitude=d.get("latitude"),
description=d.get("description"),
tags=d.get("tags", []),
created_at=source.created_at,
updated_at=source.updated_at,
)
"""Convert a ValueSource dataclass to the matching response schema."""
# AdaptiveValueSource covers both adaptive_time and adaptive_scene
if isinstance(source, AdaptiveValueSource):
if source.source_type == "adaptive_scene":
return AdaptiveSceneValueSourceResponse(
id=source.id,
name=source.name,
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
picture_source_id=source.picture_source_id,
scene_behavior=source.scene_behavior,
sensitivity=source.sensitivity,
smoothing=source.smoothing,
min_value=source.min_value,
max_value=source.max_value,
)
return AdaptiveTimeValueSourceResponse(
id=source.id,
name=source.name,
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
schedule=source.schedule,
min_value=source.min_value,
max_value=source.max_value,
)
builder = _RESPONSE_MAP.get(type(source))
if builder is None:
# Fallback for unknown types — return as static
return StaticValueSourceResponse(
id=source.id,
name=source.name,
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
value=getattr(source, "value", 1.0),
)
return builder(source)
@router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"])
async def list_value_sources(
_auth: AuthRequired,
source_type: Optional[str] = Query(None, description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene"),
source_type: Optional[str] = Query(
None,
description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene",
),
store: ValueSourceStore = Depends(get_value_source_store),
):
"""List all value sources, optionally filtered by type."""
@@ -75,34 +238,27 @@ async def list_value_sources(
)
@router.post("/api/v1/value-sources", response_model=ValueSourceResponse, status_code=201, tags=["Value Sources"])
@router.post(
"/api/v1/value-sources",
response_model=ValueSourceResponse,
status_code=201,
tags=["Value Sources"],
)
async def create_value_source(
data: ValueSourceCreate,
data: Annotated[ValueSourceCreate, Body(discriminator="source_type")],
_auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store),
):
"""Create a new value source."""
try:
# Extract all fields from the discriminated union body
fields = data.model_dump(exclude={"source_type", "name", "description", "tags"})
source = store.create_source(
name=data.name,
source_type=data.source_type,
value=data.value,
waveform=data.waveform,
speed=data.speed,
min_value=data.min_value,
max_value=data.max_value,
audio_source_id=data.audio_source_id,
mode=data.mode,
sensitivity=data.sensitivity,
smoothing=data.smoothing,
description=data.description,
schedule=data.schedule,
picture_source_id=data.picture_source_id,
scene_behavior=data.scene_behavior,
auto_gain=data.auto_gain,
use_real_time=data.use_real_time,
latitude=data.latitude,
tags=data.tags,
**fields,
)
fire_entity_event("value_source", "created", source.id)
return _to_response(source)
@@ -113,7 +269,9 @@ async def create_value_source(
raise HTTPException(status_code=400, detail=str(e))
@router.get("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
@router.get(
"/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"]
)
async def get_value_source(
source_id: str,
_auth: AuthRequired,
@@ -127,37 +285,21 @@ async def get_value_source(
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
@router.put(
"/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"]
)
async def update_value_source(
source_id: str,
data: ValueSourceUpdate,
data: Annotated[ValueSourceUpdate, Body(discriminator="source_type")],
_auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store),
pm: ProcessorManager = Depends(get_processor_manager),
):
"""Update an existing value source."""
try:
source = store.update_source(
source_id=source_id,
name=data.name,
value=data.value,
waveform=data.waveform,
speed=data.speed,
min_value=data.min_value,
max_value=data.max_value,
audio_source_id=data.audio_source_id,
mode=data.mode,
sensitivity=data.sensitivity,
smoothing=data.smoothing,
description=data.description,
schedule=data.schedule,
picture_source_id=data.picture_source_id,
scene_behavior=data.scene_behavior,
auto_gain=data.auto_gain,
use_real_time=data.use_real_time,
latitude=data.latitude,
tags=data.tags,
)
# Extract all fields, excluding None values and the discriminator
fields = data.model_dump(exclude={"source_type"}, exclude_none=True)
source = store.update_source(source_id=source_id, **fields)
# Hot-reload running value streams
pm.update_value_source(source_id)
fire_entity_event("value_source", "updated", source_id)
@@ -180,12 +322,11 @@ async def delete_value_source(
try:
# Check if any targets reference this value source
from wled_controller.storage.wled_output_target import WledOutputTarget
for target in target_store.get_all_targets():
if isinstance(target, WledOutputTarget):
if getattr(target, "brightness_value_source_id", "") == source_id:
raise ValueError(
f"Cannot delete: referenced by target '{target.name}'"
)
raise ValueError(f"Cannot delete: referenced by target '{target.name}'")
store.delete_source(source_id)
fire_entity_event("value_source", "deleted", source_id)
@@ -211,6 +352,7 @@ async def test_value_source_ws(
and streams {value: float} JSON to the client.
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -1,67 +1,149 @@
"""Audio source schemas (CRUD)."""
"""Audio source schemas — discriminated unions per source type."""
from datetime import datetime
from typing import List, Literal, Optional
from typing import Annotated, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, Discriminator, Field, Tag
# =====================================================================
# Response schemas (per-type, discriminated union)
# =====================================================================
class AudioSourceCreate(BaseModel):
"""Request to create an audio source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["multichannel", "mono", "band_extract"] = Field(description="Source type")
# multichannel fields
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
# mono fields
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
# band_extract fields
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)", ge=20, le=20000)
freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)", ge=20, le=20000)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class AudioSourceUpdate(BaseModel):
"""Request to update an audio source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)", ge=20, le=20000)
freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)", ge=20, le=20000)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class AudioSourceResponse(BaseModel):
"""Audio source response."""
class _AudioSourceResponseBase(BaseModel):
"""Shared fields for all audio source responses."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
source_type: str = Field(description="Source type: multichannel, mono, or band_extract")
device_index: Optional[int] = Field(None, description="Audio device index")
is_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)")
freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)")
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 MultichannelAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["multichannel"] = "multichannel"
device_index: int = Field(description="Audio device index (-1 = default)")
is_loopback: bool = Field(description="WASAPI loopback mode")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
class MonoAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["mono"] = "mono"
audio_source_id: str = Field(description="Parent audio source ID")
channel: str = Field(description="Channel: mono|left|right")
class BandExtractAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["band_extract"] = "band_extract"
audio_source_id: str = Field(description="Parent audio source ID")
band: str = Field(description="Band preset: bass|mid|treble|custom")
freq_low: float = Field(description="Low frequency bound (Hz)")
freq_high: float = Field(description="High frequency bound (Hz)")
AudioSourceResponse = Annotated[
Union[
Annotated[MultichannelAudioSourceResponse, Tag("multichannel")],
Annotated[MonoAudioSourceResponse, Tag("mono")],
Annotated[BandExtractAudioSourceResponse, Tag("band_extract")],
],
Discriminator("source_type"),
]
# =====================================================================
# Create schemas (per-type, discriminated union)
# =====================================================================
class _AudioSourceCreateBase(BaseModel):
"""Shared fields for all audio source create requests."""
name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class MultichannelAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["multichannel"] = "multichannel"
device_index: int = Field(-1, description="Audio device index (-1 = default)")
is_loopback: bool = Field(True, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
class MonoAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["mono"] = "mono"
audio_source_id: str = Field("", description="Parent audio source ID")
channel: str = Field("mono", description="Channel: mono|left|right")
class BandExtractAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["band_extract"] = "band_extract"
audio_source_id: str = Field("", description="Parent audio source ID")
band: str = Field("bass", description="Band preset: bass|mid|treble|custom")
freq_low: float = Field(20.0, description="Low frequency bound (Hz)", ge=20, le=20000)
freq_high: float = Field(250.0, description="High frequency bound (Hz)", ge=20, le=20000)
AudioSourceCreate = Annotated[
Union[
Annotated[MultichannelAudioSourceCreate, Tag("multichannel")],
Annotated[MonoAudioSourceCreate, Tag("mono")],
Annotated[BandExtractAudioSourceCreate, Tag("band_extract")],
],
Discriminator("source_type"),
]
# =====================================================================
# Update schemas (per-type, discriminated union)
# =====================================================================
class _AudioSourceUpdateBase(BaseModel):
"""Shared fields for all audio source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class MultichannelAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["multichannel"] = "multichannel"
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
class MonoAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["mono"] = "mono"
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
class BandExtractAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["band_extract"] = "band_extract"
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)", ge=20, le=20000)
freq_high: Optional[float] = Field(
None, description="High frequency bound (Hz)", ge=20, le=20000
)
AudioSourceUpdate = Annotated[
Union[
Annotated[MultichannelAudioSourceUpdate, Tag("multichannel")],
Annotated[MonoAudioSourceUpdate, Tag("mono")],
Annotated[BandExtractAudioSourceUpdate, Tag("band_extract")],
],
Discriminator("source_type"),
]
# =====================================================================
# List response
# =====================================================================
class AudioSourceListResponse(BaseModel):
"""List of audio sources."""
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,9 @@
"""Output target schemas (CRUD, processing state, metrics)."""
"""Output target schemas — discriminated unions per target type."""
from datetime import datetime
from typing import Any, Dict, Optional, List, Union
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, Discriminator, Field, Tag
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
@@ -43,12 +43,86 @@ class HALightMappingSchema(BaseModel):
)
class OutputTargetCreate(BaseModel):
"""Request to create an output target."""
# =====================================================================
# Response schemas (per-type, discriminated union)
# =====================================================================
class _OutputTargetResponseBase(BaseModel):
"""Shared fields for all output target responses."""
id: str = Field(description="Target ID")
name: str = Field(description="Target name")
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 LedOutputTargetResponse(_OutputTargetResponseBase):
target_type: Literal["led"] = "led"
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable)")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive"
)
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
class HALightOutputTargetResponse(_OutputTargetResponseBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: str = Field(default="", description="Home Assistant source ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
None, description="Service call rate Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
None, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
OutputTargetResponse = Annotated[
Union[
Annotated[LedOutputTargetResponse, Tag("led")],
Annotated[HALightOutputTargetResponse, Tag("ha_light")],
],
Discriminator("target_type"),
]
# =====================================================================
# Create schemas (per-type, discriminated union)
# =====================================================================
class _OutputTargetCreateBase(BaseModel):
"""Shared fields for all output target create requests."""
name: str = Field(description="Target name", min_length=1, max_length=100)
target_type: str = Field(default="led", description="Target type (led, ha_light)")
# LED target fields
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class LedOutputTargetCreate(_OutputTargetCreateBase):
target_type: Literal["led"] = "led"
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(
@@ -71,7 +145,7 @@ class OutputTargetCreate(BaseModel):
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this off",
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive"
@@ -81,32 +155,56 @@ class OutputTargetCreate(BaseModel):
pattern="^(ddp|http)$",
description="Send protocol: ddp (UDP) or http (JSON API)",
)
# HA light target fields
ha_source_id: str = Field(
default="", description="Home Assistant source ID (for ha_light targets)"
class HALightOutputTargetCreate(_OutputTargetCreateBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: str = Field(default="", description="Home Assistant source ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (for ha_light targets)"
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
default=2.0, description="Service call rate in Hz (bindable, for ha_light targets)"
default=2.0, description="Service call rate in Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
default=0.5, description="HA transition seconds (bindable, for ha_light targets)"
default=0.5, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
default=5,
description="RGB delta tolerance (bindable, for ha_light targets)",
default=5, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class OutputTargetUpdate(BaseModel):
"""Request to update an output target."""
OutputTargetCreate = Annotated[
Union[
Annotated[LedOutputTargetCreate, Tag("led")],
Annotated[HALightOutputTargetCreate, Tag("ha_light")],
],
Discriminator("target_type"),
]
# =====================================================================
# Update schemas (per-type, discriminated union)
# =====================================================================
class _OutputTargetUpdateBase(BaseModel):
"""Shared fields for all output target update requests."""
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
# LED target fields
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class LedOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["led"] = "led"
device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
@@ -126,64 +224,41 @@ class OutputTargetUpdate(BaseModel):
protocol: Optional[str] = Field(
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
)
# HA light target fields
ha_source_id: Optional[str] = Field(
None, description="Home Assistant source ID (for ha_light targets)"
)
class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (for ha_light targets)"
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
None, description="Service call rate Hz (bindable, for ha_light targets)"
None, description="Service call rate Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
None, description="HA transition seconds (bindable, for ha_light targets)"
None, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
None, description="RGB delta tolerance (bindable, for ha_light targets)"
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class OutputTargetResponse(BaseModel):
"""Output target response."""
id: str = Field(description="Target ID")
name: str = Field(description="Target name")
target_type: str = Field(description="Target type")
# LED target fields
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable)")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
None, description="Min brightness threshold (bindable, 0=disabled)"
)
adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive"
)
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
# HA light target fields
ha_source_id: str = Field(default="", description="Home Assistant source ID (ha_light)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (ha_light)"
)
update_rate: Optional[BindableFloatInput] = Field(
None, description="Service call rate Hz (bindable, ha_light)"
)
transition: Optional[BindableFloatInput] = Field(
None, description="HA transition seconds (bindable, ha_light)"
)
color_tolerance: Optional[int] = Field(None, description="RGB delta tolerance (ha_light)")
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")
OutputTargetUpdate = Annotated[
Union[
Annotated[LedOutputTargetUpdate, Tag("led")],
Annotated[HALightOutputTargetUpdate, Tag("ha_light")],
],
Discriminator("target_type"),
]
# =====================================================================
# List response & utility schemas
# =====================================================================
class OutputTargetListResponse(BaseModel):
@@ -1,80 +1,183 @@
"""Picture source schemas."""
"""Picture source schemas — discriminated unions per stream type."""
from datetime import datetime
from typing import List, Literal, Optional
from typing import Annotated, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, Discriminator, Field, Tag
# =====================================================================
# Response schemas (per-type, discriminated union)
# =====================================================================
class PictureSourceCreate(BaseModel):
"""Request to create a picture source."""
name: str = Field(description="Stream name", min_length=1, max_length=100)
stream_type: Literal["raw", "processed", "static_image", "video"] = Field(description="Stream type")
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
image_asset_id: Optional[str] = Field(None, description="Image asset ID (static_image streams)")
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
# Video fields
video_asset_id: Optional[str] = Field(None, description="Video asset ID (video streams)")
loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(None, description="Max width in pixels for decode downscale", ge=64, le=7680)
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
class PictureSourceUpdate(BaseModel):
"""Request to update a picture source."""
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
image_asset_id: Optional[str] = Field(None, description="Image asset ID (static_image streams)")
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: Optional[List[str]] = None
# Video fields
video_asset_id: Optional[str] = Field(None, description="Video asset ID (video streams)")
loop: Optional[bool] = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier", ge=0.1, le=10.0)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(None, description="Max width in pixels for decode downscale", ge=64, le=7680)
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
class PictureSourceResponse(BaseModel):
"""Picture source information response."""
class _PictureSourceResponseBase(BaseModel):
"""Shared fields for all picture source responses."""
id: str = Field(description="Stream ID")
name: str = Field(description="Stream name")
stream_type: str = Field(description="Stream type (raw, processed, static_image, or video)")
display_index: Optional[int] = Field(None, description="Display index")
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
target_fps: Optional[int] = Field(None, description="Target FPS")
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID")
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
description: Optional[str] = Field(None, description="Stream 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")
description: Optional[str] = Field(None, description="Stream description")
# Video fields
class RawPictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["raw"] = "raw"
display_index: int = Field(description="Display index")
capture_template_id: str = Field(description="Capture template ID")
target_fps: int = Field(description="Target FPS")
class ProcessedPictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["processed"] = "processed"
source_stream_id: str = Field(description="Source stream ID")
postprocessing_template_id: str = Field(description="Postprocessing template ID")
class StaticImagePictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["static_image"] = "static_image"
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
class VideoPictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["video"] = "video"
video_asset_id: Optional[str] = Field(None, description="Video asset ID")
loop: Optional[bool] = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier")
loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, description="Playback speed multiplier")
start_time: Optional[float] = Field(None, description="Trim start time in seconds")
end_time: Optional[float] = Field(None, description="Trim end time in seconds")
resolution_limit: Optional[int] = Field(None, description="Max width for decode")
clock_id: Optional[str] = Field(None, description="Sync clock ID")
target_fps: int = Field(30, description="Target FPS")
PictureSourceResponse = Annotated[
Union[
Annotated[RawPictureSourceResponse, Tag("raw")],
Annotated[ProcessedPictureSourceResponse, Tag("processed")],
Annotated[StaticImagePictureSourceResponse, Tag("static_image")],
Annotated[VideoPictureSourceResponse, Tag("video")],
],
Discriminator("stream_type"),
]
# =====================================================================
# Create schemas (per-type, discriminated union)
# =====================================================================
class _PictureSourceCreateBase(BaseModel):
"""Shared fields for all picture source create requests."""
name: str = Field(description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class RawPictureSourceCreate(_PictureSourceCreateBase):
stream_type: Literal["raw"] = "raw"
display_index: int = Field(description="Display index", ge=0)
capture_template_id: str = Field(description="Capture template ID")
target_fps: int = Field(30, description="Target FPS", ge=1, le=90)
class ProcessedPictureSourceCreate(_PictureSourceCreateBase):
stream_type: Literal["processed"] = "processed"
source_stream_id: str = Field(description="Source stream ID")
postprocessing_template_id: str = Field(description="Postprocessing template ID")
class StaticImagePictureSourceCreate(_PictureSourceCreateBase):
stream_type: Literal["static_image"] = "static_image"
image_asset_id: str = Field(description="Image asset ID")
class VideoPictureSourceCreate(_PictureSourceCreateBase):
stream_type: Literal["video"] = "video"
video_asset_id: str = Field(description="Video asset ID")
loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(
None, description="Max width in pixels for decode downscale", ge=64, le=7680
)
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
target_fps: int = Field(30, description="Target FPS", ge=1, le=90)
PictureSourceCreate = Annotated[
Union[
Annotated[RawPictureSourceCreate, Tag("raw")],
Annotated[ProcessedPictureSourceCreate, Tag("processed")],
Annotated[StaticImagePictureSourceCreate, Tag("static_image")],
Annotated[VideoPictureSourceCreate, Tag("video")],
],
Discriminator("stream_type"),
]
# =====================================================================
# Update schemas (per-type, discriminated union)
# =====================================================================
class _PictureSourceUpdateBase(BaseModel):
"""Shared fields for all picture source update requests."""
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: Optional[List[str]] = None
class RawPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["raw"] = "raw"
display_index: Optional[int] = Field(None, description="Display index", ge=0)
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
class ProcessedPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["processed"] = "processed"
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
postprocessing_template_id: Optional[str] = Field(
None, description="Postprocessing template ID"
)
class StaticImagePictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["static_image"] = "static_image"
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
class VideoPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["video"] = "video"
video_asset_id: Optional[str] = Field(None, description="Video asset ID")
loop: Optional[bool] = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field(
None, description="Playback speed multiplier", ge=0.1, le=10.0
)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(
None, description="Max width in pixels for decode downscale", ge=64, le=7680
)
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
PictureSourceUpdate = Annotated[
Union[
Annotated[RawPictureSourceUpdate, Tag("raw")],
Annotated[ProcessedPictureSourceUpdate, Tag("processed")],
Annotated[StaticImagePictureSourceUpdate, Tag("static_image")],
Annotated[VideoPictureSourceUpdate, Tag("video")],
],
Discriminator("stream_type"),
]
# =====================================================================
# List response
# =====================================================================
class PictureSourceListResponse(BaseModel):
@@ -84,11 +187,23 @@ class PictureSourceListResponse(BaseModel):
count: int = Field(description="Number of streams")
# =====================================================================
# Test / Validation (unchanged)
# =====================================================================
class PictureSourceTestRequest(BaseModel):
"""Request to test a picture source."""
capture_duration: float = Field(default=5.0, ge=0.0, le=30.0, description="Duration to capture in seconds (0 = single frame)")
border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels for preview")
capture_duration: float = Field(
default=5.0,
ge=0.0,
le=30.0,
description="Duration to capture in seconds (0 = single frame)",
)
border_width: int = Field(
default=10, ge=1, le=100, description="Border width in pixels for preview"
)
class ImageValidateRequest(BaseModel):
@@ -1,95 +1,402 @@
"""Value source schemas (CRUD)."""
"""Value source schemas — discriminated unions per source type."""
from datetime import datetime
from typing import List, Literal, Optional
from typing import Annotated, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, Discriminator, Field, Tag
# =====================================================================
# Response schemas (per-type, discriminated union)
# =====================================================================
class ValueSourceCreate(BaseModel):
"""Request to create a value source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["static", "animated", "audio", "adaptive_time", "adaptive_scene", "daylight"] = Field(description="Source type")
# static fields
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
# animated fields
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
# audio fields
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels to full range")
# adaptive fields
schedule: Optional[list] = Field(None, description="Time-of-day schedule: [{time: 'HH:MM', value: 0.0-1.0}]")
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
# daylight fields
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class ValueSourceUpdate(BaseModel):
"""Request to update a value source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
# static fields
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
# animated fields
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
# audio fields
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels to full range")
# adaptive fields
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
# daylight fields
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class ValueSourceResponse(BaseModel):
"""Value source response."""
class _ValueSourceResponseBase(BaseModel):
"""Shared fields for all value source responses."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
source_type: str = Field(description="Source type: static, animated, audio, adaptive_time, or adaptive_scene")
value: Optional[float] = Field(None, description="Static value")
waveform: Optional[str] = Field(None, description="Waveform type")
speed: Optional[float] = Field(None, description="Cycles per minute")
min_value: Optional[float] = Field(None, description="Minimum output")
max_value: Optional[float] = Field(None, description="Maximum output")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode")
sensitivity: Optional[float] = Field(None, description="Gain multiplier")
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels")
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Geographic latitude")
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 StaticValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["static"] = "static"
return_type: Literal["float"] = "float"
value: float = Field(description="Constant value (0.0-1.0)")
class AnimatedValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["animated"] = "animated"
return_type: Literal["float"] = "float"
waveform: str = Field(description="Waveform type")
speed: float = Field(description="Cycles per minute")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
class AudioValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["audio"] = "audio"
return_type: Literal["float"] = "float"
audio_source_id: str = Field(description="Mono audio source ID")
mode: str = Field(description="Audio mode: rms|peak|beat")
sensitivity: float = Field(description="Gain multiplier")
smoothing: float = Field(description="Temporal smoothing")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
auto_gain: bool = Field(description="Auto-normalize audio levels")
class AdaptiveTimeValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["adaptive_time"] = "adaptive_time"
return_type: Literal["float"] = "float"
schedule: list = Field(description="Time-of-day schedule")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
class AdaptiveSceneValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["adaptive_scene"] = "adaptive_scene"
return_type: Literal["float"] = "float"
picture_source_id: str = Field(description="Picture source ID")
scene_behavior: str = Field(description="Scene behavior: complement|match")
sensitivity: float = Field(description="Gain multiplier")
smoothing: float = Field(description="Temporal smoothing")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
class DaylightValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["daylight"] = "daylight"
return_type: Literal["float"] = "float"
speed: float = Field(description="Simulation speed multiplier")
use_real_time: bool = Field(description="Use wall-clock time")
latitude: float = Field(description="Geographic latitude")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
class StaticColorValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["static_color"] = "static_color"
return_type: Literal["color"] = "color"
color: List[int] = Field(description="Static RGB color [R,G,B]")
class AnimatedColorValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["animated_color"] = "animated_color"
return_type: Literal["color"] = "color"
colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]")
speed: float = Field(description="Cycles per minute")
easing: str = Field(description="Color easing: linear|step")
class AdaptiveTimeColorValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
return_type: Literal["color"] = "color"
schedule: list = Field(description="Color schedule")
class HAEntityValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["ha_entity"] = "ha_entity"
return_type: Literal["float"] = "float"
ha_source_id: str = Field(description="Home Assistant source ID")
entity_id: str = Field(description="HA entity ID (e.g. sensor.temperature)")
attribute: str = Field("", description="Optional attribute name (empty = use state)")
min_ha_value: float = Field(description="Raw HA value mapped to output 0.0")
max_ha_value: float = Field(description="Raw HA value mapped to output 1.0")
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
class GradientMapValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["gradient_map"] = "gradient_map"
return_type: Literal["color"] = "color"
value_source_id: str = Field(description="Input float value source ID")
gradient_id: str = Field(description="Gradient entity ID")
easing: str = Field(description="Interpolation mode: linear|step")
class CSSExtractValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["css_extract"] = "css_extract"
return_type: Literal["color"] = "color"
color_strip_source_id: str = Field(description="Color strip source ID")
led_start: int = Field(description="Start of LED range (0-based)")
led_end: int = Field(description="End of LED range (-1 = whole strip)")
ValueSourceResponse = Annotated[
Union[
Annotated[StaticValueSourceResponse, Tag("static")],
Annotated[AnimatedValueSourceResponse, Tag("animated")],
Annotated[AudioValueSourceResponse, Tag("audio")],
Annotated[AdaptiveTimeValueSourceResponse, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceResponse, Tag("adaptive_scene")],
Annotated[DaylightValueSourceResponse, Tag("daylight")],
Annotated[StaticColorValueSourceResponse, Tag("static_color")],
Annotated[AnimatedColorValueSourceResponse, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceResponse, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceResponse, Tag("ha_entity")],
Annotated[GradientMapValueSourceResponse, Tag("gradient_map")],
Annotated[CSSExtractValueSourceResponse, Tag("css_extract")],
],
Discriminator("source_type"),
]
# =====================================================================
# Create schemas (per-type, discriminated union)
# =====================================================================
class _ValueSourceCreateBase(BaseModel):
"""Shared fields for all value source create requests."""
name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class StaticValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["static"] = "static"
value: float = Field(1.0, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
class AnimatedValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["animated"] = "animated"
waveform: str = Field("sine", description="Waveform: sine|triangle|square|sawtooth")
speed: float = Field(10.0, description="Cycles per minute", ge=0.1, le=120.0)
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
class AudioValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["audio"] = "audio"
audio_source_id: str = Field("", description="Mono audio source ID")
mode: str = Field("rms", description="Audio mode: rms|peak|beat")
sensitivity: float = Field(1.0, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
smoothing: float = Field(0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
auto_gain: bool = Field(False, description="Auto-normalize audio levels to full range")
class AdaptiveTimeValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["adaptive_time"] = "adaptive_time"
schedule: list = Field(description="Schedule: [{time: 'HH:MM', value: 0.0-1.0}]")
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
class AdaptiveSceneValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["adaptive_scene"] = "adaptive_scene"
picture_source_id: str = Field("", description="Picture source ID for scene mode")
scene_behavior: str = Field("complement", description="Scene behavior: complement|match")
sensitivity: float = Field(1.0, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
smoothing: float = Field(0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
class DaylightValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["daylight"] = "daylight"
speed: float = Field(1.0, description="Simulation speed multiplier", ge=0.1, le=120.0)
use_real_time: bool = Field(False, description="Use wall-clock time instead of simulation")
latitude: float = Field(50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
class StaticColorValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["static_color"] = "static_color"
color: List[int] = Field(
default_factory=lambda: [255, 255, 255],
description="Static RGB color [R,G,B]",
)
class AnimatedColorValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["animated_color"] = "animated_color"
colors: List[List[int]] = Field(
default_factory=lambda: [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
description="Color list [[R,G,B], ...]",
)
speed: float = Field(10.0, description="Cycles per minute", ge=0.1, le=120.0)
easing: str = Field("linear", description="Color easing: linear|step")
class AdaptiveTimeColorValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
schedule: list = Field(description="Schedule: [{time: 'HH:MM', color: [R,G,B]}]")
class HAEntityValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["ha_entity"] = "ha_entity"
ha_source_id: str = Field(description="Home Assistant source ID")
entity_id: str = Field(description="HA entity ID")
attribute: str = Field("", description="Optional attribute name")
min_ha_value: float = Field(0.0, description="Raw HA value mapped to output 0.0")
max_ha_value: float = Field(100.0, description="Raw HA value mapped to output 1.0")
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
class GradientMapValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["gradient_map"] = "gradient_map"
value_source_id: str = Field(description="Input float value source ID")
gradient_id: str = Field("", description="Gradient entity ID")
easing: str = Field("linear", description="Interpolation: linear|step")
class CSSExtractValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["css_extract"] = "css_extract"
color_strip_source_id: str = Field(description="Color strip source ID")
led_start: int = Field(0, description="Start of LED range (0-based)", ge=0)
led_end: int = Field(-1, description="End of LED range (-1 = whole strip)")
ValueSourceCreate = Annotated[
Union[
Annotated[StaticValueSourceCreate, Tag("static")],
Annotated[AnimatedValueSourceCreate, Tag("animated")],
Annotated[AudioValueSourceCreate, Tag("audio")],
Annotated[AdaptiveTimeValueSourceCreate, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceCreate, Tag("adaptive_scene")],
Annotated[DaylightValueSourceCreate, Tag("daylight")],
Annotated[StaticColorValueSourceCreate, Tag("static_color")],
Annotated[AnimatedColorValueSourceCreate, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceCreate, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceCreate, Tag("ha_entity")],
Annotated[GradientMapValueSourceCreate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceCreate, Tag("css_extract")],
],
Discriminator("source_type"),
]
# =====================================================================
# Update schemas (per-type, discriminated union)
# =====================================================================
class _ValueSourceUpdateBase(BaseModel):
"""Shared fields for all value source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class StaticValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["static"] = "static"
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
class AnimatedValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["animated"] = "animated"
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
class AudioValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["audio"] = "audio"
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: Optional[float] = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels")
class AdaptiveTimeValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_time"] = "adaptive_time"
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
class AdaptiveSceneValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_scene"] = "adaptive_scene"
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
sensitivity: Optional[float] = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
class DaylightValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["daylight"] = "daylight"
speed: Optional[float] = Field(None, description="Simulation speed", ge=0.1, le=120.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Geographic latitude", ge=-90.0, le=90.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
class StaticColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["static_color"] = "static_color"
color: Optional[List[int]] = Field(None, description="Static RGB color [R,G,B]")
class AnimatedColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["animated_color"] = "animated_color"
colors: Optional[List[List[int]]] = Field(None, description="Color list [[R,G,B], ...]")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
easing: Optional[str] = Field(None, description="Color easing: linear|step")
class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
schedule: Optional[list] = Field(None, description="Color schedule")
class HAEntityValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["ha_entity"] = "ha_entity"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
entity_id: Optional[str] = Field(None, description="HA entity ID")
attribute: Optional[str] = Field(None, description="Attribute name")
min_ha_value: Optional[float] = Field(None, description="Min HA value")
max_ha_value: Optional[float] = Field(None, description="Max HA value")
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
class GradientMapValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["gradient_map"] = "gradient_map"
value_source_id: Optional[str] = Field(None, description="Input value source ID")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
easing: Optional[str] = Field(None, description="Interpolation mode")
class CSSExtractValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["css_extract"] = "css_extract"
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
led_start: Optional[int] = Field(None, description="LED range start", ge=0)
led_end: Optional[int] = Field(None, description="LED range end")
ValueSourceUpdate = Annotated[
Union[
Annotated[StaticValueSourceUpdate, Tag("static")],
Annotated[AnimatedValueSourceUpdate, Tag("animated")],
Annotated[AudioValueSourceUpdate, Tag("audio")],
Annotated[AdaptiveTimeValueSourceUpdate, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceUpdate, Tag("adaptive_scene")],
Annotated[DaylightValueSourceUpdate, Tag("daylight")],
Annotated[StaticColorValueSourceUpdate, Tag("static_color")],
Annotated[AnimatedColorValueSourceUpdate, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceUpdate, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")],
Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")],
],
Discriminator("source_type"),
]
# =====================================================================
# List response
# =====================================================================
class ValueSourceListResponse(BaseModel):
"""List of value sources."""