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