diff --git a/TODO.md b/TODO.md index 433618c..6b39bf7 100644 --- a/TODO.md +++ b/TODO.md @@ -1,38 +1,231 @@ -# BindableFloat — Universal Value Source Binding +# New Value Source Types + Filter Support -## ALL PHASES COMPLETE +## Feature 1: HA Value Source (`ha_entity`) -### Phase 1: Core Infrastructure +A value source that reads numeric values from a Home Assistant entity's state or attribute. Allows binding any scalar property in the system to a live HA sensor/entity value. -- [x] `storage/bindable.py` — BindableFloat dataclass + `bfloat()` extraction helper -- [x] WledOutputTarget, HALightOutputTarget, HALightMapping — brightness/transition -- [x] All 15 CSS source types — smoothing, sensitivity, intensity, scale, speed, etc. -- [x] API schemas + routes updated -- [x] output_target_store create/update -- [x] processor_manager add_target / add_ha_light_target +### Configuration +- `ha_source_id: str` — HA connection entity (EntitySelect picker) +- `entity_id: str` — HA entity (EntitySelect picker, populated from `/api/v1/home-assistant/sources/{id}/entities`) +- `attribute: str` — optional attribute name (text input or dropdown populated from entity attributes) +- `min_ha_value: float` — raw HA value corresponding to output 0.0 +- `max_ha_value: float` — raw HA value corresponding to output 1.0 +- `smoothing: float` — EMA smoothing factor (0..1) +- `return_type: "float"` — always float -### Phase 2: Runtime Resolution +### Backend -- [x] WledTargetProcessor — BindableFloat brightness, acquire/release value streams -- [x] HALightTargetProcessor — BindableFloat brightness + transition -- [x] All CSS streams use `bfloat()` to extract static values from BindableFloat properties -- [x] scene_activator — brightness_changed flag -- [x] ColorStripStream base class — `resolve()`, `set_value_stream()`, `remove_value_stream()` -- [x] ColorStripStreamManager — `_bind_value_streams()` / `_release_value_streams()` on acquire/release -- [x] All stream hot loops call `self.resolve(prop, static)` for dynamic runtime binding -- [x] KeyColorsColorStripStream — fixed to inherit from ColorStripStream +- [ ] **Storage model** — `HAEntityValueSource` subclass in `storage/value_source.py` + - Fields: `ha_source_id`, `entity_id`, `attribute`, `min_ha_value`, `max_ha_value`, `smoothing` + - Register in `_VALUE_SOURCE_MAP` as `"ha_entity"` + - `to_dict()` / `from_dict()` / `_parse_common_fields()` -### Phase 3: Frontend +- [ ] **Store** — add `"ha_entity"` case in `ValueSourceStore.create_source()` and `update_source()` + - Validate: `ha_source_id` must be non-empty, `entity_id` must be non-empty -- [x] TypeScript BindableFloat type + `bindableValue()` / `bindableSourceId()` helpers -- [x] targets.ts, ha-light-targets.ts, color-strips.ts — save/load/display -- [x] Graph connections — value source edges for ALL bindable CSS properties -- [x] Graph layout — edge creation for CSS + target bindable properties -- [x] custom_components/select.py — HA integration backward compat +- [ ] **API schemas** — `HAEntityValueSourceCreate`, `HAEntityValueSourceResponse` in `api/schemas/value_sources.py` + - Add to `ValueSourceCreate` / `ValueSourceResponse` discriminated unions + - Fields: `ha_source_id`, `entity_id`, `attribute` (optional), `min_ha_value`, `max_ha_value`, `smoothing` -### Phase 4: BindableScalarWidget +- [ ] **API routes** — add `HAEntityValueSource` → response builder in `_RESPONSE_MAP` -- [x] `core/bindable-scalar.ts` — reusable widget (slider + VS picker toggle) -- [x] CSS styles (`.bindable-toggle`, `.bindable-slider-row`, `.bindable-vs-row`) -- [x] All 11 CSS editor sliders converted (smoothing, sensitivity, intensity, scale, speed, wind, temp_influence, timeout) -- [x] HTML templates updated with container divs +- [ ] **Stream** — `HAEntityValueStream` in `core/processing/value_stream.py` + - `start()`: acquire HA runtime via `ha_manager.acquire(ha_source_id)` + - `get_value()`: read `ha_manager.get_state(ha_source_id, entity_id)` → extract state or attribute → clamp/normalize to [0,1] via min/max range → apply EMA smoothing + - `stop()`: release HA runtime + - `update_source()`: hot-update parameters + - Add to `ValueStreamManager._create_stream()` + +### Frontend + +- [ ] **TypeScript type** — `HAEntityValueSource` interface in `types.ts` + - `source_type: 'ha_entity'`, `return_type: 'float'` + - Fields: `ha_source_id`, `entity_id`, `attribute`, `min_ha_value`, `max_ha_value`, `smoothing` + - Add to `ValueSourceType` union and `ValueSource` union + +- [ ] **Icon** — add `ha_entity: _svg(P.home)` to `_valueSourceTypeIcons` in `icons.ts` + +- [ ] **i18n** — add keys in `en.json`: + - `value_source.type.ha_entity`: "Home Assistant Entity" + - `value_source.type.ha_entity.desc`: "Reads value from a Home Assistant sensor or entity attribute" + - `value_source.ha_source`: "HA Connection:" + - `value_source.entity_id`: "Entity:" + - `value_source.attribute`: "Attribute (optional):" + - `value_source.min_ha_value`: "Min HA Value:" + - `value_source.max_ha_value`: "Max HA Value:" + +- [ ] **Editor modal** — add `ha_entity` section to `value-source-editor.html` + - HA connection selector (EntitySelect from HA sources cache) + - Entity selector (EntitySelect populated from HA entities endpoint) + - Attribute text input (optional) + - Min/Max HA value range inputs + - Smoothing slider + +- [ ] **Editor logic** — add `ha_entity` handler in `value-sources.ts` + - `onValueSourceTypeChange()`: show/hide ha_entity section + - `_typeHandlers['ha_entity']`: load/reset/getPayload + - EntitySelect for HA source + EntitySelect for entity (refreshes when HA source changes) + - Auto-name: "{entity_friendly_name}" or "{entity_id}" + +- [ ] **Card renderer** — show HA source link + entity ID + attribute (if set) + range +- [ ] **VS_TYPE_KEYS** — add `'ha_entity'` to the array + +--- + +## Feature 2: Lerp Color Value Source (`gradient_map`) + +A color value source that maps a numeric value source's output through a color gradient. Given a float value source (0..1), interpolates the color at that position in a user-defined gradient. + +### Configuration +- `value_source_id: str` — reference to a float-returning value source (EntitySelect) +- `stops: List[ColorStop]` — gradient color stops `[{position: float, color: [R,G,B]}]` (reuse existing `ColorStop` model from color strip sources) +- `easing: str` — interpolation mode: "linear", "step" (reuse existing easing modes) +- `return_type: "color"` — always color + +### Backend + +- [ ] **Storage model** — `GradientMapValueSource` subclass in `storage/value_source.py` + - Fields: `value_source_id`, `stops` (list of dicts with `position` + `color`), `easing` + - Register in `_VALUE_SOURCE_MAP` as `"gradient_map"` + +- [ ] **Store** — add `"gradient_map"` case in `create_source()` / `update_source()` + - Validate: at least 2 stops, `value_source_id` non-empty + +- [ ] **API schemas** — `GradientMapValueSourceCreate`, `GradientMapValueSourceResponse` + - Reuse `ColorStop` schema from color_strip_sources schemas (or define minimal version) + - Add to discriminated unions + +- [ ] **API routes** — add to `_RESPONSE_MAP` + +- [ ] **Stream** — `GradientMapValueStream` in `value_stream.py` + - `start()`: acquire the referenced value stream via `ValueStreamManager.acquire(value_source_id)` + - `get_value()`: return BT.601 luminance of current color + - `get_color()`: call `inner_stream.get_value()` → interpolate through gradient stops → return RGB tuple + - Reuse `_compute_gradient_colors()` logic from color_strip_stream.py (or a shared helper for single-point interpolation) + - `stop()`: release inner value stream + - `update_source()`: hot-update stops/easing, re-acquire if value_source_id changed + +### Frontend + +- [ ] **TypeScript type** — `GradientMapValueSource` interface + - `source_type: 'gradient_map'`, `return_type: 'color'` + - Fields: `value_source_id`, `stops: ColorStop[]`, `easing` + +- [ ] **Icon** — add `gradient_map: _svg(P.rainbow)` to `_valueSourceTypeIcons` + +- [ ] **i18n** — add keys: + - `value_source.type.gradient_map`: "Gradient Map" + - `value_source.type.gradient_map.desc`: "Maps a numeric value through a color gradient" + - `value_source.input_source`: "Input Value Source:" + - `value_source.gradient_stops`: "Gradient:" + - `value_source.easing`: "Interpolation:" + +- [ ] **Editor modal** — add `gradient_map` section + - Value source selector (EntitySelect from float value sources) + - Gradient stop editor (reuse gradient stop UI from CSS editor if possible, or build minimal version: list of position + color picker rows) + - Easing selector (IconSelect: linear, step) + - Live gradient preview bar (CSS linear-gradient from stops) + +- [ ] **Editor logic** — `_typeHandlers['gradient_map']`: load/reset/getPayload +- [ ] **Card renderer** — CSS gradient preview bar + input source link + stop count +- [ ] **VS_TYPE_KEYS** — add `'gradient_map'` + +--- + +## Feature 3: CSS Extraction Color Value Source (`css_extract`) + +A color value source that extracts a single color from a color strip source by averaging a range of LEDs. Useful for deriving a single color signal from an existing color strip. + +### Configuration +- `color_strip_source_id: str` — reference to a color strip source (EntitySelect) +- `led_start: int` — start of LED range (0-based, optional, default 0) +- `led_end: int` — end of LED range (exclusive, optional, default -1 = whole strip) +- `return_type: "color"` — always color + +### Backend + +- [ ] **Storage model** — `CSSExtractValueSource` subclass in `storage/value_source.py` + - Fields: `color_strip_source_id`, `led_start`, `led_end` + - Register as `"css_extract"` + +- [ ] **Store** — add `"css_extract"` case in `create_source()` / `update_source()` + - Validate: `color_strip_source_id` non-empty + +- [ ] **API schemas** — `CSSExtractValueSourceCreate`, `CSSExtractValueSourceResponse` + - Add to discriminated unions + +- [ ] **API routes** — add to `_RESPONSE_MAP` + +- [ ] **Stream** — `CSSExtractValueStream` in `value_stream.py` + - `start()`: acquire color strip stream via `ColorStripStreamManager.acquire(color_strip_source_id, led_count=needed)` + - `get_color()`: read strip colors → average the specified LED range → return single RGB tuple + - `get_value()`: BT.601 luminance of extracted color + - `stop()`: release color strip stream + - `update_source()`: hot-update range, re-acquire if source changed + - **Note**: Needs access to `ColorStripStreamManager` — may need to inject it into `ValueStreamManager` or pass via constructor + +### Frontend + +- [ ] **TypeScript type** — `CSSExtractValueSource` interface + - `source_type: 'css_extract'`, `return_type: 'color'` + - Fields: `color_strip_source_id`, `led_start`, `led_end` + +- [ ] **Icon** — add `css_extract: _svg(P.eyedropper)` (or `P.pipette` if available, else `P.palette`) + +- [ ] **i18n** — add keys: + - `value_source.type.css_extract`: "Strip Color Extract" + - `value_source.type.css_extract.desc`: "Extracts a single color from a color strip source" + - `value_source.color_strip_source`: "Color Strip Source:" + - `value_source.led_start`: "LED Start:" + - `value_source.led_end`: "LED End (-1 = all):" + +- [ ] **Editor modal** — add `css_extract` section + - Color strip source selector (EntitySelect from color strip sources cache) + - LED start/end numeric inputs + - Optional: live color preview swatch + +- [ ] **Editor logic** — `_typeHandlers['css_extract']`: load/reset/getPayload +- [ ] **Card renderer** — color strip source link + LED range badge +- [ ] **VS_TYPE_KEYS** — add `'css_extract'` + +--- + +## Feature 4: Value Source Type Filter in Icon Grid + +Add a filter/category system to the value source type IconSelect so users can filter by return type or category. + +### Implementation + +- [ ] **Add filter tabs** above the value source type icon grid in the editor modal + - "All" (default) — show all types + - "Float" — show float-returning types: static, animated, audio, adaptive_time, adaptive_scene, daylight, ha_entity + - "Color" — show color-returning types: static_color, animated_color, adaptive_time_color, gradient_map, css_extract + +- [ ] **IconSelect enhancement** — either: + - Option A: Add `groups` support to IconSelect (items grouped by category with filter tabs) + - Option B: Filter `VS_TYPE_KEYS` before building items, with toggle buttons above the grid + - Decision: Option B is simpler and follows existing patterns — add filter buttons that rebuild the icon grid + +- [ ] **i18n** — add keys: + - `value_source.filter.all`: "All" + - `value_source.filter.float`: "Float" + - `value_source.filter.color`: "Color" + +--- + +## Implementation Order + +1. **Feature 4** (filter) — smallest, unblocks better UX for the growing type list +2. **Feature 1** (ha_entity) — standalone float type, no cross-dependencies +3. **Feature 3** (css_extract) — needs ColorStripStreamManager injection +4. **Feature 2** (gradient_map) — needs float VS reference + gradient UI + +## Cross-Cutting Concerns + +- All new types need entries in `_VALUE_SOURCE_MAP` (backend) and `VS_TYPE_KEYS` (frontend) +- All new types need `_RESPONSE_MAP` entries in routes +- All new types need `ValueStreamManager._create_stream()` factory case +- All new types need icon in `_valueSourceTypeIcons` +- All new types need i18n keys in `en.json` (and `ru.json`, `zh.json` — can defer translations) +- `ValueSourceStore` referential integrity check on delete should verify new references (ha_entity → ha_source, gradient_map → value_source, css_extract → color_strip_source) +- Graph editor: new edge types for ha_entity → HA source node, gradient_map → value source node, css_extract → color strip node diff --git a/server/src/wled_controller/api/routes/audio_sources.py b/server/src/wled_controller/api/routes/audio_sources.py index 570fc30..6d1a4e3 100644 --- a/server/src/wled_controller/api/routes/audio_sources.py +++ b/server/src/wled_controller/api/routes/audio_sources.py @@ -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: diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index 5ca0c42..841946b 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -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), diff --git a/server/src/wled_controller/api/routes/output_targets.py b/server/src/wled_controller/api/routes/output_targets.py index eb1fb84..1e586b5 100644 --- a/server/src/wled_controller/api/routes/output_targets.py +++ b/server/src/wled_controller/api/routes/output_targets.py @@ -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: diff --git a/server/src/wled_controller/api/routes/picture_sources.py b/server/src/wled_controller/api/routes/picture_sources.py index f5ea31e..e0dfee9 100644 --- a/server/src/wled_controller/api/routes/picture_sources.py +++ b/server/src/wled_controller/api/routes/picture_sources.py @@ -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, ) diff --git a/server/src/wled_controller/api/routes/value_sources.py b/server/src/wled_controller/api/routes/value_sources.py index 0e3fbd3..c147f4c 100644 --- a/server/src/wled_controller/api/routes/value_sources.py +++ b/server/src/wled_controller/api/routes/value_sources.py @@ -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 diff --git a/server/src/wled_controller/api/schemas/audio_sources.py b/server/src/wled_controller/api/schemas/audio_sources.py index 07785d4..ac64304 100644 --- a/server/src/wled_controller/api/schemas/audio_sources.py +++ b/server/src/wled_controller/api/schemas/audio_sources.py @@ -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.""" diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index f0c167a..27818c1 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -1,13 +1,18 @@ -"""Color strip source schemas (CRUD).""" +"""Color strip source schemas — discriminated unions per source type.""" from datetime import datetime -from typing import Dict, List, Literal, Optional +from typing import Annotated, Any, Dict, List, Literal, Optional, Union -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Discriminator, Field, Tag, model_validator from wled_controller.api.schemas.devices import Calibration +# ===================================================================== +# Helper models (unchanged) +# ===================================================================== + + class AppSoundOverride(BaseModel): """Per-application sound override for notification sources.""" @@ -24,16 +29,16 @@ class AnimationConfig(BaseModel): enabled: bool = True type: str = "breathing" # breathing | color_cycle | gradient_shift | wave - speed: float = Field(1.0, ge=0.1, le=10.0, description="Speed multiplier (0.1–10.0)") + speed: float = Field(1.0, ge=0.1, le=10.0, description="Speed multiplier (0.1-10.0)") class ColorStop(BaseModel): """A single color stop in a gradient.""" position: float = Field( - description="Relative position along the strip (0.0–1.0)", ge=0.0, le=1.0 + description="Relative position along the strip (0.0-1.0)", ge=0.0, le=1.0 ) - color: List[int] = Field(description="Primary RGB color [R, G, B] (0–255 each)") + color: List[int] = Field(description="Primary RGB color [R, G, B] (0-255 each)") color_right: Optional[List[int]] = Field( None, description="Optional right-side RGB color for a hard edge (bidirectional stop)", @@ -71,444 +76,543 @@ class MappedZone(BaseModel): reverse: bool = Field(default=False, description="Reverse zone output") -class ColorStripSourceCreate(BaseModel): - """Request to create a color strip source.""" - - name: str = Field(description="Source name", min_length=1, max_length=100) - source_type: Literal[ - "picture", - "picture_advanced", - "static", - "gradient", - "color_cycle", - "effect", - "composite", - "mapped", - "audio", - "api_input", - "notification", - "daylight", - "candlelight", - "processed", - "weather", - "key_colors", - ] = Field(default="picture", description="Source type") - # picture-type fields - picture_source_id: str = Field(default="", description="Picture source ID (for picture type)") - smoothing: float = Field( - default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0 - ) - interpolation_mode: str = Field( - default="average", description="LED color interpolation mode (average, median, dominant)" - ) - calibration: Optional[Calibration] = Field( - None, description="LED calibration (position and count per edge)" - ) - # static-type fields - color: Optional[List[int]] = Field( - None, description="Static RGB color [R, G, B] (0-255 each, for static type)" - ) - # gradient-type fields - stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type") - # color_cycle-type fields - colors: Optional[List[List[int]]] = Field( - None, description="List of [R,G,B] colors to cycle (color_cycle type)" - ) - # effect-type fields - effect_type: Optional[str] = Field( - None, - description="Effect algorithm: fire|meteor|plasma|noise|aurora|rain|comet|bouncing_ball|fireworks|sparkle_rain|lava_lamp|wave_interference", - ) - palette: Optional[str] = Field( - None, - description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice) or 'custom'", - ) - intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0) - scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0) - mirror: Optional[bool] = Field(None, description="Mirror/bounce mode (meteor/comet)") - custom_palette: Optional[List[List[float]]] = Field( - None, description="Custom palette stops [[pos,R,G,B],...]" - ) - # gradient entity reference (effect, gradient, audio types) - gradient_id: Optional[str] = Field( - None, description="Gradient entity ID (overrides palette/inline stops)" - ) - # gradient-type easing - easing: Optional[str] = Field( - None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic" - ) - # composite-type fields - layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") - # mapped-type fields - zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type") - # audio-type fields - visualization_mode: Optional[str] = Field( - None, description="Audio visualization: spectrum|beat_pulse|vu_meter" - ) - audio_source_id: Optional[str] = Field( - None, description="Mono audio source ID (for audio type)" - ) - sensitivity: Optional[float] = Field( - None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0 - ) - color_peak: Optional[List[int]] = Field( - None, description="Peak/high RGB color for VU meter [R,G,B]" - ) - # shared - led_count: int = Field( - default=0, description="Total LED count (0 = auto from calibration / device)", ge=0 - ) - description: Optional[str] = Field(None, description="Optional description", max_length=500) - animation: Optional[AnimationConfig] = Field( - None, description="Procedural animation config (static/gradient only)" - ) - # api_input-type fields - fallback_color: Optional[List[int]] = Field( - None, description="Fallback RGB color [R,G,B] when no data received (api_input type)" - ) - timeout: Optional[float] = Field( - None, description="Seconds before reverting to fallback (api_input type)", ge=0.0, le=300.0 - ) - interpolation: Optional[str] = Field( - None, description="LED count interpolation mode: none|linear|nearest (api_input type)" - ) - # notification-type fields - notification_effect: Optional[str] = Field( - None, description="Notification effect: flash|pulse|sweep" - ) - duration_ms: Optional[int] = Field( - None, ge=100, le=10000, description="Effect duration in milliseconds" - ) - default_color: Optional[str] = Field( - None, description="Default hex color (#RRGGBB) for notifications" - ) - app_colors: Optional[Dict[str, str]] = Field( - None, description="Map of app name to hex color (#RRGGBB)" - ) - app_filter_mode: Optional[str] = Field( - None, description="App filter mode: off|whitelist|blacklist" - ) - app_filter_list: Optional[List[str]] = Field(None, description="App names for filter") - os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications") - sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID") - sound_volume: Optional[float] = Field( - None, ge=0.0, le=1.0, description="Global notification sound volume" - ) - app_sounds: Optional[Dict[str, AppSoundOverride]] = Field( - None, description="Per-app sound overrides" - ) - # daylight-type fields - speed: Optional[float] = Field( - None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0 - ) - use_real_time: Optional[bool] = Field( - None, description="Use wall-clock time for daylight cycle" - ) - latitude: Optional[float] = Field( - None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0 - ) - longitude: Optional[float] = Field( - None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0 - ) - # candlelight-type fields - num_candles: Optional[int] = Field( - None, description="Number of independent candle sources (1-20)", ge=1, le=20 - ) - wind_strength: Optional[float] = Field( - None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0 - ) - candle_type: Optional[str] = Field( - None, description="Candle type preset: default|taper|votive|bonfire" - ) - # processed-type fields - input_source_id: Optional[str] = Field( - None, description="Input color strip source ID (for processed type)" - ) - processing_template_id: Optional[str] = Field( - None, description="Color strip processing template ID (for processed type)" - ) - # weather-type fields - weather_source_id: Optional[str] = Field( - None, description="Weather source entity ID (for weather type)" - ) - temperature_influence: Optional[float] = Field( - None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0 - ) - # key_colors-type fields - rectangles: Optional[List[dict]] = Field( - None, description="Named screen regions [{name,x,y,width,height}] for key_colors type" - ) - brightness: Optional[float] = Field( - None, description="Static brightness (0.0-1.0)", ge=0.0, le=1.0 - ) - brightness_value_source_id: Optional[str] = Field( - None, description="Dynamic brightness value source ID" - ) - # sync clock - clock_id: Optional[str] = Field( - None, description="Optional sync clock ID for synchronized animation" - ) - tags: List[str] = Field(default_factory=list, description="User-defined tags") +# ===================================================================== +# Response schemas (per-type, discriminated union) +# ===================================================================== -class ColorStripSourceUpdate(BaseModel): - """Request to update a color strip source.""" - - name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) - # picture-type fields - picture_source_id: Optional[str] = Field(None, description="Picture source ID") - smoothing: Optional[float] = Field( - None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0 - ) - interpolation_mode: Optional[str] = Field( - None, description="Interpolation mode (average, median, dominant)" - ) - calibration: Optional[Calibration] = Field(None, description="LED calibration") - # static-type fields - color: Optional[List[int]] = Field( - None, description="Static RGB color [R, G, B] (0-255 each, for static type)" - ) - # gradient-type fields - stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type") - # color_cycle-type fields - colors: Optional[List[List[int]]] = Field( - None, description="List of [R,G,B] colors to cycle (color_cycle type)" - ) - # effect-type fields - effect_type: Optional[str] = Field(None, description="Effect algorithm") - palette: Optional[str] = Field(None, description="Named palette") - intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0) - scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0) - mirror: Optional[bool] = Field(None, description="Mirror/bounce mode") - custom_palette: Optional[List[List[float]]] = Field( - None, description="Custom palette stops [[pos,R,G,B],...]" - ) - # gradient entity reference (effect, gradient, audio types) - gradient_id: Optional[str] = Field( - None, description="Gradient entity ID (overrides palette/inline stops)" - ) - # gradient-type easing - easing: Optional[str] = Field( - None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic" - ) - # composite-type fields - layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") - # mapped-type fields - zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type") - # audio-type fields - visualization_mode: Optional[str] = Field( - None, description="Audio visualization: spectrum|beat_pulse|vu_meter" - ) - audio_source_id: Optional[str] = Field( - None, description="Mono audio source ID (for audio type)" - ) - sensitivity: Optional[float] = Field( - None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0 - ) - color_peak: Optional[List[int]] = Field( - None, description="Peak/high RGB color for VU meter [R,G,B]" - ) - # shared - led_count: Optional[int] = Field( - None, description="Total LED count (0 = auto from calibration / device)", ge=0 - ) - description: Optional[str] = Field(None, description="Optional description", max_length=500) - animation: Optional[AnimationConfig] = Field( - None, description="Procedural animation config (static/gradient only)" - ) - # api_input-type fields - fallback_color: Optional[List[int]] = Field( - None, description="Fallback RGB color [R,G,B] (api_input type)" - ) - timeout: Optional[float] = Field( - None, description="Timeout before fallback (api_input type)", ge=0.0, le=300.0 - ) - interpolation: Optional[str] = Field( - None, description="LED count interpolation mode: none|linear|nearest (api_input type)" - ) - # notification-type fields - notification_effect: Optional[str] = Field( - None, description="Notification effect: flash|pulse|sweep" - ) - duration_ms: Optional[int] = Field( - None, ge=100, le=10000, description="Effect duration in milliseconds" - ) - default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB)") - app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color") - app_filter_mode: Optional[str] = Field( - None, description="App filter mode: off|whitelist|blacklist" - ) - app_filter_list: Optional[List[str]] = Field(None, description="App names for filter") - os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications") - sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID") - sound_volume: Optional[float] = Field( - None, ge=0.0, le=1.0, description="Global notification sound volume" - ) - app_sounds: Optional[Dict[str, AppSoundOverride]] = Field( - None, description="Per-app sound overrides" - ) - # daylight-type fields - speed: Optional[float] = Field( - None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0 - ) - use_real_time: Optional[bool] = Field( - None, description="Use wall-clock time for daylight cycle" - ) - latitude: Optional[float] = Field( - None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0 - ) - longitude: Optional[float] = Field( - None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0 - ) - # candlelight-type fields - num_candles: Optional[int] = Field( - None, description="Number of independent candle sources (1-20)", ge=1, le=20 - ) - wind_strength: Optional[float] = Field( - None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0 - ) - candle_type: Optional[str] = Field( - None, description="Candle type preset: default|taper|votive|bonfire" - ) - # processed-type fields - input_source_id: Optional[str] = Field( - None, description="Input color strip source ID (for processed type)" - ) - processing_template_id: Optional[str] = Field( - None, description="Color strip processing template ID (for processed type)" - ) - # weather-type fields - weather_source_id: Optional[str] = Field( - None, description="Weather source entity ID (for weather type)" - ) - temperature_influence: Optional[float] = Field( - None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0 - ) - # key_colors-type fields - rectangles: Optional[List[dict]] = Field( - None, description="Named screen regions for key_colors type" - ) - brightness: Optional[float] = Field( - None, description="Static brightness (0.0-1.0)", ge=0.0, le=1.0 - ) - brightness_value_source_id: Optional[str] = Field( - None, description="Dynamic brightness value source ID" - ) - # sync clock - clock_id: Optional[str] = Field( - None, description="Optional sync clock ID for synchronized animation" - ) - tags: Optional[List[str]] = None - - -class ColorStripSourceResponse(BaseModel): - """Color strip source response.""" +class _CSSResponseBase(BaseModel): + """Shared fields for all color strip source responses.""" id: str = Field(description="Source ID") name: str = Field(description="Source name") - source_type: str = Field(description="Source type") - # picture-type fields - picture_source_id: Optional[str] = Field(None, description="Picture source ID") - smoothing: Optional[float] = Field(None, description="Temporal smoothing") - interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") - calibration: Optional[Calibration] = Field(None, description="LED calibration") - # static-type fields - color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B]") - # gradient-type fields - stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type") - # color_cycle-type fields - colors: Optional[List[List[int]]] = Field( - None, description="List of [R,G,B] colors to cycle (color_cycle type)" - ) - # effect-type fields - effect_type: Optional[str] = Field(None, description="Effect algorithm") - palette: Optional[str] = Field(None, description="Named palette") - intensity: Optional[float] = Field(None, description="Effect intensity") - scale: Optional[float] = Field(None, description="Spatial scale") - mirror: Optional[bool] = Field(None, description="Mirror/bounce mode") - custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops") - gradient_id: Optional[str] = Field(None, description="Gradient entity ID") - # gradient-type easing - easing: Optional[str] = Field(None, description="Gradient interpolation easing") - # composite-type fields - layers: Optional[List[dict]] = Field(None, description="Layers for composite type") - # mapped-type fields - zones: Optional[List[dict]] = Field(None, description="Zones for mapped type") - # audio-type fields - visualization_mode: Optional[str] = Field(None, description="Audio visualization mode") - audio_source_id: Optional[str] = Field(None, description="Mono audio source ID") - sensitivity: Optional[float] = Field(None, description="Audio sensitivity") - color_peak: Optional[List[int]] = Field(None, description="Peak color [R,G,B]") - # shared - led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)") description: Optional[str] = Field(None, description="Description") - animation: Optional[AnimationConfig] = Field( - None, description="Procedural animation config (static/gradient only)" - ) - # api_input-type fields - fallback_color: Optional[List[int]] = Field( - None, description="Fallback RGB color [R,G,B] (api_input type)" - ) - timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)") - interpolation: Optional[str] = Field( - None, description="LED count interpolation mode: none|linear|nearest (api_input type)" - ) - # notification-type fields - notification_effect: Optional[str] = Field( - None, description="Notification effect: flash|pulse|sweep" - ) - duration_ms: Optional[int] = Field(None, description="Effect duration in milliseconds") - default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB)") - app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color") - app_filter_mode: Optional[str] = Field( - None, description="App filter mode: off|whitelist|blacklist" - ) - app_filter_list: Optional[List[str]] = Field(None, description="App names for filter") - os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications") - sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID") - sound_volume: Optional[float] = Field(None, description="Global notification sound volume") - app_sounds: Optional[Dict[str, dict]] = Field(None, description="Per-app sound overrides") - # daylight-type fields - speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier") - use_real_time: Optional[bool] = Field( - None, description="Use wall-clock time for daylight cycle" - ) - latitude: Optional[float] = Field(None, description="Latitude for daylight timing") - longitude: Optional[float] = Field(None, description="Longitude for daylight timing") - # candlelight-type fields - num_candles: Optional[int] = Field(None, description="Number of independent candle sources") - wind_strength: Optional[float] = Field(None, description="Wind simulation strength") - candle_type: Optional[str] = Field(None, description="Candle type preset") - # processed-type fields - input_source_id: Optional[str] = Field(None, description="Input color strip source ID") - processing_template_id: Optional[str] = Field( - None, description="Color strip processing template ID" - ) - # weather-type fields - weather_source_id: Optional[str] = Field(None, description="Weather source entity ID") - temperature_influence: Optional[float] = Field( - None, description="Temperature color shift strength" - ) - # key_colors-type fields - rectangles: Optional[List[dict]] = Field( - None, description="Named screen regions for key_colors type" - ) - brightness: Optional[float] = Field(None, description="Static brightness (0.0-1.0)") - brightness_value_source_id: Optional[str] = Field( - None, description="Dynamic brightness value source ID" - ) - # sync clock - clock_id: Optional[str] = Field( - None, description="Optional sync clock ID for synchronized animation" - ) - tags: List[str] = Field(default_factory=list, description="User-defined tags") + led_count: int = Field(0, description="Total LED count (0 = auto)") overlay_active: bool = Field( False, description="Whether the screen overlay is currently active" ) + clock_id: Optional[str] = Field(None, description="Optional sync clock ID") + 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 PictureCSSResponse(_CSSResponseBase): + source_type: Literal["picture"] = "picture" + picture_source_id: str = Field(description="Picture source ID") + smoothing: Any = Field(description="Temporal smoothing") + interpolation_mode: str = Field(description="Interpolation mode") + calibration: Optional[Calibration] = Field(None, description="LED calibration") + + +class PictureAdvancedCSSResponse(_CSSResponseBase): + source_type: Literal["picture_advanced"] = "picture_advanced" + smoothing: Any = Field(description="Temporal smoothing") + interpolation_mode: str = Field(description="Interpolation mode") + calibration: Optional[Calibration] = Field(None, description="LED calibration") + + +class StaticCSSResponse(_CSSResponseBase): + source_type: Literal["static"] = "static" + color: Any = Field(description="Static RGB color") + animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") + + +class GradientCSSResponse(_CSSResponseBase): + source_type: Literal["gradient"] = "gradient" + stops: Optional[List[ColorStop]] = Field(None, description="Color stops") + animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") + easing: str = Field(description="Gradient interpolation easing") + gradient_id: Optional[str] = Field(None, description="Gradient entity ID") + + +class ColorCycleCSSResponse(_CSSResponseBase): + source_type: Literal["color_cycle"] = "color_cycle" + colors: List[List[int]] = Field(description="List of [R,G,B] colors to cycle") + + +class EffectCSSResponse(_CSSResponseBase): + source_type: Literal["effect"] = "effect" + effect_type: str = Field(description="Effect algorithm") + palette: str = Field(description="Named palette") + gradient_id: Optional[str] = Field(None, description="Gradient entity ID") + color: Any = Field(description="Primary color") + intensity: Any = Field(description="Effect intensity") + scale: Any = Field(description="Spatial scale") + mirror: bool = Field(description="Mirror/bounce mode") + custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops") + + +class CompositeCSSResponse(_CSSResponseBase): + source_type: Literal["composite"] = "composite" + layers: List[dict] = Field(default_factory=list, description="Composite layers") + + +class MappedCSSResponse(_CSSResponseBase): + source_type: Literal["mapped"] = "mapped" + zones: List[dict] = Field(default_factory=list, description="Mapped zones") + + +class AudioCSSResponse(_CSSResponseBase): + source_type: Literal["audio"] = "audio" + visualization_mode: str = Field(description="Audio visualization mode") + audio_source_id: str = Field(description="Mono audio source ID") + sensitivity: Any = Field(description="Audio sensitivity") + smoothing: Any = Field(description="Temporal smoothing") + palette: str = Field(description="Named palette") + gradient_id: Optional[str] = Field(None, description="Gradient entity ID") + color: Any = Field(description="Primary color") + color_peak: Any = Field(description="Peak color") + mirror: bool = Field(description="Mirror mode") + + +class ApiInputCSSResponse(_CSSResponseBase): + source_type: Literal["api_input"] = "api_input" + fallback_color: Any = Field(description="Fallback RGB color") + timeout: Any = Field(description="Timeout before fallback") + interpolation: str = Field(description="LED count interpolation mode") + + +class NotificationCSSResponse(_CSSResponseBase): + source_type: Literal["notification"] = "notification" + notification_effect: str = Field(description="Notification effect") + duration_ms: Any = Field(description="Effect duration in milliseconds") + default_color: Any = Field(None, description="Default color") + app_colors: Dict[str, str] = Field(default_factory=dict, description="Per-app hex colors") + app_filter_mode: str = Field(description="App filter mode") + app_filter_list: List[str] = Field(default_factory=list, description="App names for filter") + os_listener: bool = Field(description="Whether to listen for OS notifications") + sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID") + sound_volume: Any = Field(description="Global notification sound volume") + app_sounds: Dict[str, dict] = Field(default_factory=dict, description="Per-app sound overrides") + + +class DaylightCSSResponse(_CSSResponseBase): + source_type: Literal["daylight"] = "daylight" + speed: Any = Field(description="Cycle speed multiplier") + use_real_time: bool = Field(description="Use wall-clock time") + latitude: float = Field(description="Latitude for daylight timing") + longitude: float = Field(description="Longitude for daylight timing") + + +class CandlelightCSSResponse(_CSSResponseBase): + source_type: Literal["candlelight"] = "candlelight" + color: Any = Field(description="Candle color") + intensity: Any = Field(description="Candle intensity") + num_candles: int = Field(description="Number of independent candle sources") + speed: Any = Field(description="Flicker speed multiplier") + wind_strength: Any = Field(description="Wind simulation strength") + candle_type: str = Field(description="Candle type preset") + + +class ProcessedCSSResponse(_CSSResponseBase): + source_type: Literal["processed"] = "processed" + input_source_id: str = Field(description="Input color strip source ID") + processing_template_id: str = Field(description="Color strip processing template ID") + + +class WeatherCSSResponse(_CSSResponseBase): + source_type: Literal["weather"] = "weather" + weather_source_id: str = Field(description="Weather source entity ID") + speed: Any = Field(description="Speed multiplier") + temperature_influence: Any = Field(description="Temperature color shift strength") + + +class KeyColorsCSSResponse(_CSSResponseBase): + source_type: Literal["key_colors"] = "key_colors" + picture_source_id: str = Field(description="Picture source ID") + rectangles: List[dict] = Field(default_factory=list, description="Named screen regions") + interpolation_mode: str = Field(description="Interpolation mode") + smoothing: Any = Field(description="Temporal smoothing") + brightness: Any = Field(description="Brightness") + + +ColorStripSourceResponse = Annotated[ + Union[ + Annotated[PictureCSSResponse, Tag("picture")], + Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")], + Annotated[StaticCSSResponse, Tag("static")], + Annotated[GradientCSSResponse, Tag("gradient")], + Annotated[ColorCycleCSSResponse, Tag("color_cycle")], + Annotated[EffectCSSResponse, Tag("effect")], + Annotated[CompositeCSSResponse, Tag("composite")], + Annotated[MappedCSSResponse, Tag("mapped")], + Annotated[AudioCSSResponse, Tag("audio")], + Annotated[ApiInputCSSResponse, Tag("api_input")], + Annotated[NotificationCSSResponse, Tag("notification")], + Annotated[DaylightCSSResponse, Tag("daylight")], + Annotated[CandlelightCSSResponse, Tag("candlelight")], + Annotated[ProcessedCSSResponse, Tag("processed")], + Annotated[WeatherCSSResponse, Tag("weather")], + Annotated[KeyColorsCSSResponse, Tag("key_colors")], + ], + Discriminator("source_type"), +] + + +# ===================================================================== +# Create schemas (per-type, discriminated union) +# ===================================================================== + + +class _CSSCreateBase(BaseModel): + """Shared fields for all color strip source create requests.""" + + name: str = Field(description="Source name", min_length=1, max_length=100) + led_count: int = Field(default=0, description="Total LED count (0 = auto)", ge=0) + description: Optional[str] = Field(None, description="Optional description", max_length=500) + clock_id: Optional[str] = Field(None, description="Optional sync clock ID") + tags: List[str] = Field(default_factory=list, description="User-defined tags") + + +class PictureCSSCreate(_CSSCreateBase): + source_type: Literal["picture"] = "picture" + picture_source_id: str = Field(default="", description="Picture source ID") + smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)") + interpolation_mode: str = Field(default="average", description="Interpolation mode") + calibration: Optional[Calibration] = Field(None, description="LED calibration") + + +class PictureAdvancedCSSCreate(_CSSCreateBase): + source_type: Literal["picture_advanced"] = "picture_advanced" + smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)") + interpolation_mode: str = Field(default="average", description="Interpolation mode") + calibration: Optional[Calibration] = Field(None, description="LED calibration") + + +class StaticCSSCreate(_CSSCreateBase): + source_type: Literal["static"] = "static" + color: Any = Field(default=None, description="Static RGB color [R,G,B]") + animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") + + +class GradientCSSCreate(_CSSCreateBase): + source_type: Literal["gradient"] = "gradient" + stops: Optional[List[ColorStop]] = Field(None, description="Color stops") + animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") + easing: Optional[str] = Field(None, description="Gradient easing") + gradient_id: Optional[str] = Field(None, description="Gradient entity ID") + + +class ColorCycleCSSCreate(_CSSCreateBase): + source_type: Literal["color_cycle"] = "color_cycle" + colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle") + + +class EffectCSSCreate(_CSSCreateBase): + source_type: Literal["effect"] = "effect" + effect_type: Optional[str] = Field(None, description="Effect algorithm") + palette: Optional[str] = Field(None, description="Named palette") + gradient_id: Optional[str] = Field(None, description="Gradient entity ID") + color: Any = Field(default=None, description="Primary color") + intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)") + scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)") + mirror: Optional[bool] = Field(None, description="Mirror/bounce mode") + custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops") + + +class CompositeCSSCreate(_CSSCreateBase): + source_type: Literal["composite"] = "composite" + layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") + + +class MappedCSSCreate(_CSSCreateBase): + source_type: Literal["mapped"] = "mapped" + zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type") + + +class AudioCSSCreate(_CSSCreateBase): + source_type: Literal["audio"] = "audio" + visualization_mode: Optional[str] = Field(None, description="Audio visualization mode") + audio_source_id: Optional[str] = Field(None, description="Mono audio source ID") + sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)") + smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)") + palette: Optional[str] = Field(None, description="Named palette") + gradient_id: Optional[str] = Field(None, description="Gradient entity ID") + color: Any = Field(default=None, description="Primary color") + color_peak: Any = Field(default=None, description="Peak color [R,G,B]") + mirror: Optional[bool] = Field(None, description="Mirror mode") + + +class ApiInputCSSCreate(_CSSCreateBase): + source_type: Literal["api_input"] = "api_input" + fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]") + timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)") + interpolation: Optional[str] = Field(None, description="LED count interpolation mode") + + +class NotificationCSSCreate(_CSSCreateBase): + source_type: Literal["notification"] = "notification" + notification_effect: Optional[str] = Field(None, description="Notification effect") + duration_ms: Any = Field(default=None, description="Effect duration in milliseconds") + default_color: Optional[Union[List[int], Dict[str, Any], str]] = Field( + None, description="Default color" + ) + app_colors: Optional[Dict[str, str]] = Field(None, description="Per-app hex colors") + app_filter_mode: Optional[str] = Field(None, description="App filter mode") + app_filter_list: Optional[List[str]] = Field(None, description="App names for filter") + os_listener: Optional[bool] = Field(None, description="Listen for OS notifications") + sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID") + sound_volume: Any = Field(default=None, description="Global notification sound volume") + app_sounds: Optional[Dict[str, AppSoundOverride]] = Field( + None, description="Per-app sound overrides" + ) + + +class DaylightCSSCreate(_CSSCreateBase): + source_type: Literal["daylight"] = "daylight" + speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)") + use_real_time: Optional[bool] = Field(None, description="Use wall-clock time") + latitude: Optional[float] = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0) + longitude: Optional[float] = Field( + None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0 + ) + + +class CandlelightCSSCreate(_CSSCreateBase): + source_type: Literal["candlelight"] = "candlelight" + color: Any = Field(default=None, description="Candle color [R,G,B]") + intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)") + num_candles: Optional[int] = Field( + None, description="Number of candle sources (1-20)", ge=1, le=20 + ) + speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)") + wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)") + candle_type: Optional[str] = Field(None, description="Candle type preset") + + +class ProcessedCSSCreate(_CSSCreateBase): + source_type: Literal["processed"] = "processed" + input_source_id: Optional[str] = Field(None, description="Input color strip source ID") + processing_template_id: Optional[str] = Field(None, description="Processing template ID") + + +class WeatherCSSCreate(_CSSCreateBase): + source_type: Literal["weather"] = "weather" + weather_source_id: Optional[str] = Field(None, description="Weather source entity ID") + speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)") + temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)") + + +class KeyColorsCSSCreate(_CSSCreateBase): + source_type: Literal["key_colors"] = "key_colors" + picture_source_id: str = Field(default="", description="Picture source ID") + rectangles: Optional[List[dict]] = Field(None, description="Named screen regions") + interpolation_mode: str = Field(default="average", description="Interpolation mode") + smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)") + brightness: Any = Field(default=None, description="Brightness (0.0-1.0)") + brightness_value_source_id: Optional[str] = Field( + None, description="Dynamic brightness value source ID" + ) + + +ColorStripSourceCreate = Annotated[ + Union[ + Annotated[PictureCSSCreate, Tag("picture")], + Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")], + Annotated[StaticCSSCreate, Tag("static")], + Annotated[GradientCSSCreate, Tag("gradient")], + Annotated[ColorCycleCSSCreate, Tag("color_cycle")], + Annotated[EffectCSSCreate, Tag("effect")], + Annotated[CompositeCSSCreate, Tag("composite")], + Annotated[MappedCSSCreate, Tag("mapped")], + Annotated[AudioCSSCreate, Tag("audio")], + Annotated[ApiInputCSSCreate, Tag("api_input")], + Annotated[NotificationCSSCreate, Tag("notification")], + Annotated[DaylightCSSCreate, Tag("daylight")], + Annotated[CandlelightCSSCreate, Tag("candlelight")], + Annotated[ProcessedCSSCreate, Tag("processed")], + Annotated[WeatherCSSCreate, Tag("weather")], + Annotated[KeyColorsCSSCreate, Tag("key_colors")], + ], + Discriminator("source_type"), +] + + +# ===================================================================== +# Update schemas (per-type, discriminated union) +# ===================================================================== + + +class _CSSUpdateBase(BaseModel): + """Shared fields for all color strip source update requests.""" + + name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) + led_count: Optional[int] = Field(None, description="Total LED count (0 = auto)", ge=0) + description: Optional[str] = Field(None, description="Optional description", max_length=500) + clock_id: Optional[str] = Field(None, description="Optional sync clock ID") + tags: Optional[List[str]] = None + + +class PictureCSSUpdate(_CSSUpdateBase): + source_type: Literal["picture"] = "picture" + picture_source_id: Optional[str] = Field(None, description="Picture source ID") + smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)") + interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") + calibration: Optional[Calibration] = Field(None, description="LED calibration") + + +class PictureAdvancedCSSUpdate(_CSSUpdateBase): + source_type: Literal["picture_advanced"] = "picture_advanced" + smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)") + interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") + calibration: Optional[Calibration] = Field(None, description="LED calibration") + + +class StaticCSSUpdate(_CSSUpdateBase): + source_type: Literal["static"] = "static" + color: Any = Field(default=None, description="Static RGB color [R,G,B]") + animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") + + +class GradientCSSUpdate(_CSSUpdateBase): + source_type: Literal["gradient"] = "gradient" + stops: Optional[List[ColorStop]] = Field(None, description="Color stops") + animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") + easing: Optional[str] = Field(None, description="Gradient easing") + gradient_id: Optional[str] = Field(None, description="Gradient entity ID") + + +class ColorCycleCSSUpdate(_CSSUpdateBase): + source_type: Literal["color_cycle"] = "color_cycle" + colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle") + + +class EffectCSSUpdate(_CSSUpdateBase): + source_type: Literal["effect"] = "effect" + effect_type: Optional[str] = Field(None, description="Effect algorithm") + palette: Optional[str] = Field(None, description="Named palette") + gradient_id: Optional[str] = Field(None, description="Gradient entity ID") + color: Any = Field(default=None, description="Primary color") + intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)") + scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)") + mirror: Optional[bool] = Field(None, description="Mirror/bounce mode") + custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops") + + +class CompositeCSSUpdate(_CSSUpdateBase): + source_type: Literal["composite"] = "composite" + layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") + + +class MappedCSSUpdate(_CSSUpdateBase): + source_type: Literal["mapped"] = "mapped" + zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type") + + +class AudioCSSUpdate(_CSSUpdateBase): + source_type: Literal["audio"] = "audio" + visualization_mode: Optional[str] = Field(None, description="Audio visualization mode") + audio_source_id: Optional[str] = Field(None, description="Mono audio source ID") + sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)") + smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)") + palette: Optional[str] = Field(None, description="Named palette") + gradient_id: Optional[str] = Field(None, description="Gradient entity ID") + color: Any = Field(default=None, description="Primary color") + color_peak: Any = Field(default=None, description="Peak color [R,G,B]") + mirror: Optional[bool] = Field(None, description="Mirror mode") + + +class ApiInputCSSUpdate(_CSSUpdateBase): + source_type: Literal["api_input"] = "api_input" + fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]") + timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)") + interpolation: Optional[str] = Field(None, description="LED count interpolation mode") + + +class NotificationCSSUpdate(_CSSUpdateBase): + source_type: Literal["notification"] = "notification" + notification_effect: Optional[str] = Field(None, description="Notification effect") + duration_ms: Any = Field(default=None, description="Effect duration in milliseconds") + default_color: Optional[Union[List[int], Dict[str, Any], str]] = Field( + None, description="Default color" + ) + app_colors: Optional[Dict[str, str]] = Field(None, description="Per-app hex colors") + app_filter_mode: Optional[str] = Field(None, description="App filter mode") + app_filter_list: Optional[List[str]] = Field(None, description="App names for filter") + os_listener: Optional[bool] = Field(None, description="Listen for OS notifications") + sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID") + sound_volume: Any = Field(default=None, description="Global notification sound volume") + app_sounds: Optional[Dict[str, AppSoundOverride]] = Field( + None, description="Per-app sound overrides" + ) + + +class DaylightCSSUpdate(_CSSUpdateBase): + source_type: Literal["daylight"] = "daylight" + speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)") + use_real_time: Optional[bool] = Field(None, description="Use wall-clock time") + latitude: Optional[float] = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0) + longitude: Optional[float] = Field( + None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0 + ) + + +class CandlelightCSSUpdate(_CSSUpdateBase): + source_type: Literal["candlelight"] = "candlelight" + color: Any = Field(default=None, description="Candle color [R,G,B]") + intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)") + num_candles: Optional[int] = Field( + None, description="Number of candle sources (1-20)", ge=1, le=20 + ) + speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)") + wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)") + candle_type: Optional[str] = Field(None, description="Candle type preset") + + +class ProcessedCSSUpdate(_CSSUpdateBase): + source_type: Literal["processed"] = "processed" + input_source_id: Optional[str] = Field(None, description="Input color strip source ID") + processing_template_id: Optional[str] = Field(None, description="Processing template ID") + + +class WeatherCSSUpdate(_CSSUpdateBase): + source_type: Literal["weather"] = "weather" + weather_source_id: Optional[str] = Field(None, description="Weather source entity ID") + speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)") + temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)") + + +class KeyColorsCSSUpdate(_CSSUpdateBase): + source_type: Literal["key_colors"] = "key_colors" + picture_source_id: Optional[str] = Field(None, description="Picture source ID") + rectangles: Optional[List[dict]] = Field(None, description="Named screen regions") + interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") + smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)") + brightness: Any = Field(default=None, description="Brightness (0.0-1.0)") + brightness_value_source_id: Optional[str] = Field( + None, description="Dynamic brightness value source ID" + ) + + +ColorStripSourceUpdate = Annotated[ + Union[ + Annotated[PictureCSSUpdate, Tag("picture")], + Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")], + Annotated[StaticCSSUpdate, Tag("static")], + Annotated[GradientCSSUpdate, Tag("gradient")], + Annotated[ColorCycleCSSUpdate, Tag("color_cycle")], + Annotated[EffectCSSUpdate, Tag("effect")], + Annotated[CompositeCSSUpdate, Tag("composite")], + Annotated[MappedCSSUpdate, Tag("mapped")], + Annotated[AudioCSSUpdate, Tag("audio")], + Annotated[ApiInputCSSUpdate, Tag("api_input")], + Annotated[NotificationCSSUpdate, Tag("notification")], + Annotated[DaylightCSSUpdate, Tag("daylight")], + Annotated[CandlelightCSSUpdate, Tag("candlelight")], + Annotated[ProcessedCSSUpdate, Tag("processed")], + Annotated[WeatherCSSUpdate, Tag("weather")], + Annotated[KeyColorsCSSUpdate, Tag("key_colors")], + ], + Discriminator("source_type"), +] + + +# ===================================================================== +# List response +# ===================================================================== + + class ColorStripSourceListResponse(BaseModel): """List of color strip sources.""" @@ -516,6 +620,11 @@ class ColorStripSourceListResponse(BaseModel): count: int = Field(description="Number of sources") +# ===================================================================== +# Request-only schemas (unchanged) +# ===================================================================== + + class SegmentPayload(BaseModel): """A single segment for segment-based LED color updates.""" diff --git a/server/src/wled_controller/api/schemas/output_targets.py b/server/src/wled_controller/api/schemas/output_targets.py index e466aaf..3feb7a7 100644 --- a/server/src/wled_controller/api/schemas/output_targets.py +++ b/server/src/wled_controller/api/schemas/output_targets.py @@ -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): diff --git a/server/src/wled_controller/api/schemas/picture_sources.py b/server/src/wled_controller/api/schemas/picture_sources.py index a68b911..f9de864 100644 --- a/server/src/wled_controller/api/schemas/picture_sources.py +++ b/server/src/wled_controller/api/schemas/picture_sources.py @@ -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): diff --git a/server/src/wled_controller/api/schemas/value_sources.py b/server/src/wled_controller/api/schemas/value_sources.py index d5f0705..b3f8c45 100644 --- a/server/src/wled_controller/api/schemas/value_sources.py +++ b/server/src/wled_controller/api/schemas/value_sources.py @@ -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.""" diff --git a/server/src/wled_controller/core/processing/api_input_stream.py b/server/src/wled_controller/core/processing/api_input_stream.py index 66598e9..ed60d78 100644 --- a/server/src/wled_controller/core/processing/api_input_stream.py +++ b/server/src/wled_controller/core/processing/api_input_stream.py @@ -43,10 +43,9 @@ class ApiInputColorStripStream(ColorStripStream): self._fps = 30 # Parse config - fallback = source.fallback_color - self._fallback_color = ( - fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0] - ) + from wled_controller.storage.bindable import bcolor + + self._fallback_color = bcolor(source.fallback_color, [0, 0, 0]) from wled_controller.storage.bindable import bfloat self._timeout = max(0.0, bfloat(source.timeout, 5.0)) diff --git a/server/src/wled_controller/core/processing/audio_stream.py b/server/src/wled_controller/core/processing/audio_stream.py index c83f86f..72999a8 100644 --- a/server/src/wled_controller/core/processing/audio_stream.py +++ b/server/src/wled_controller/core/processing/audio_stream.py @@ -93,12 +93,10 @@ class AudioColorStripStream(ColorStripStream): self._gradient_id = getattr(source, "gradient_id", None) self._palette_name = getattr(source, "palette", "rainbow") self._resolve_palette_lut() - color = getattr(source, "color", None) - self._color = color if isinstance(color, list) and len(color) == 3 else [0, 255, 0] - color_peak = getattr(source, "color_peak", None) - self._color_peak = ( - color_peak if isinstance(color_peak, list) and len(color_peak) == 3 else [255, 0, 0] - ) + from wled_controller.storage.bindable import bcolor + + self._color = bcolor(getattr(source, "color", None), [0, 255, 0]) + self._color_peak = bcolor(getattr(source, "color_peak", None), [255, 0, 0]) # Pre-computed float arrays for VU meter (avoid per-frame np.array()) self._color_f = np.array(self._color, dtype=np.float32) self._color_peak_f = np.array(self._color_peak, dtype=np.float32) @@ -426,8 +424,8 @@ class AudioColorStripStream(ColorStripStream): buf[:] = 0 if fill_count > 0: - base = self._color_f - peak = self._color_peak_f + base = np.array(self.resolve_color("color", self._color), dtype=np.float32) + peak = np.array(self.resolve_color("color_peak", self._color_peak), dtype=np.float32) t = self._vu_gradient[:fill_count] for ch in range(3): buf[:fill_count, ch] = np.clip(base[ch] + (peak[ch] - base[ch]) * t, 0, 255).astype( diff --git a/server/src/wled_controller/core/processing/candlelight_stream.py b/server/src/wled_controller/core/processing/candlelight_stream.py index 09a62aa..2f6ae66 100644 --- a/server/src/wled_controller/core/processing/candlelight_stream.py +++ b/server/src/wled_controller/core/processing/candlelight_stream.py @@ -85,10 +85,9 @@ class CandlelightColorStripStream(ColorStripStream): self._update_from_source(source) def _update_from_source(self, source) -> None: - raw_color = getattr(source, "color", None) - self._color = ( - raw_color if isinstance(raw_color, list) and len(raw_color) == 3 else [255, 147, 41] - ) + from wled_controller.storage.bindable import bcolor + + self._color = bcolor(getattr(source, "color", None), [255, 147, 41]) from wled_controller.storage.bindable import bfloat self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0) @@ -261,7 +260,8 @@ class CandlelightColorStripStream(ColorStripStream): eff_speed = speed * 0.35 * spd_mul intensity = self.resolve("intensity", self._intensity) num_candles = self._num_candles - base_r, base_g, base_b = self._color[0], self._color[1], self._color[2] + _c = self.resolve_color("color", self._color) + base_r, base_g, base_b = _c[0], _c[1], _c[2] # Wind modulation wind_strength = self.resolve("wind_strength", self._wind_strength) diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py index ba2009d..d85a2bf 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -25,7 +25,7 @@ from wled_controller.core.capture.calibration import ( create_pixel_mapper, ) from wled_controller.core.capture.screen_capture import extract_border_pixels -from wled_controller.storage.bindable import bfloat +from wled_controller.storage.bindable import bcolor, bfloat from wled_controller.utils import get_logger from wled_controller.utils.timer import high_resolution_timer @@ -136,6 +136,18 @@ class ColorStripStream(ABC): pass return static + def resolve_color(self, prop: str, static: list) -> list: + """Resolve a bindable color: ValueStream color if bound, else static [R,G,B].""" + if self._value_streams: + vs = self._value_streams.get(prop) + if vs is not None: + try: + c = vs.get_color() + return [c[0], c[1], c[2]] + except Exception: + pass + return static + class PictureColorStripStream(ColorStripStream): """Color strip stream backed by a LiveStream (picture source). @@ -507,12 +519,7 @@ class StaticColorStripStream(ColorStripStream): self._update_from_source(source) def _update_from_source(self, source) -> None: - color = ( - source.color - if isinstance(source.color, list) and len(source.color) == 3 - else [255, 255, 255] - ) - self._source_color = color # stored separately so configure() can rebuild + self._source_color = bcolor(source.color, [255, 255, 255]) _lc = getattr(source, "led_count", 0) self._auto_size = not _lc led_count = _lc if _lc and _lc > 0 else 1 @@ -643,7 +650,7 @@ class StaticColorStripStream(ColorStripStream): if atype == "breathing": factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) - r, g, b = self._source_color + r, g, b = self.resolve_color("color", self._source_color) buf[:] = ( min(255, int(r * factor)), min(255, int(g * factor)), @@ -655,14 +662,14 @@ class StaticColorStripStream(ColorStripStream): # Square wave: on for half the period, off for the other half. # speed=1.0 → 2 flashes/sec (one full on/off cycle per 0.5s) if math.sin(2 * math.pi * speed * t * 2.0) >= 0: - buf[:] = self._source_color + buf[:] = self.resolve_color("color", self._source_color) else: buf[:] = 0 colors = buf elif atype == "sparkle": # Random LEDs flash white while the rest stay the base color - buf[:] = self._source_color + buf[:] = self.resolve_color("color", self._source_color) density = min(0.5, 0.1 * speed) mask = np.random.random(n) < density buf[mask] = (255, 255, 255) @@ -676,7 +683,7 @@ class StaticColorStripStream(ColorStripStream): factor = phase / 0.1 else: factor = math.exp(-5.0 * (phase - 0.1)) - r, g, b = self._source_color + r, g, b = self.resolve_color("color", self._source_color) buf[:] = ( min(255, int(r * factor)), min(255, int(g * factor)), @@ -691,7 +698,7 @@ class StaticColorStripStream(ColorStripStream): flicker += 0.15 * math.sin(2 * math.pi * speed * t * 7.3) flicker += 0.10 * (np.random.random() - 0.5) factor = max(0.2, min(1.0, base_factor + flicker)) - r, g, b = self._source_color + r, g, b = self.resolve_color("color", self._source_color) buf[:] = ( min(255, int(r * factor)), min(255, int(g * factor)), @@ -701,7 +708,7 @@ class StaticColorStripStream(ColorStripStream): elif atype == "rainbow_fade": # Shift hue continuously from the base color - r, g, b = self._source_color + r, g, b = self.resolve_color("color", self._source_color) h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0) # speed=1.0 → one full hue rotation every ~10s h_shift = (speed * t * 0.1) % 1.0 diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py index 4b7da27..ff593e4 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -147,7 +147,7 @@ class ColorStripStreamManager: logger.debug("Sync clock release during stream cleanup: %s", e) pass # source may have been deleted already - # Properties that can be BindableFloat on any CSS source + # Properties that can be BindableFloat or BindableColor on any CSS source _BINDABLE_PROPS = ( "smoothing", "sensitivity", @@ -160,19 +160,23 @@ class ColorStripStreamManager: "timeout", "brightness", "duration_ms", + "color", + "color_peak", + "fallback_color", + "default_color", ) def _bind_value_streams( self, css_stream: ColorStripStream, source, entry: _ColorStripEntry ) -> None: - """Acquire ValueStreams for any bound BindableFloat properties and inject into stream.""" + """Acquire ValueStreams for any bound BindableFloat/BindableColor properties.""" if not self._value_stream_manager: return - from wled_controller.storage.bindable import BindableFloat + from wled_controller.storage.bindable import BindableColor, BindableFloat for prop in self._BINDABLE_PROPS: bf = getattr(source, prop, None) - if isinstance(bf, BindableFloat) and bf.source_id: + if isinstance(bf, (BindableFloat, BindableColor)) and bf.source_id: try: vs = self._value_stream_manager.acquire(bf.source_id) css_stream.set_value_stream(prop, vs) diff --git a/server/src/wled_controller/core/processing/effect_stream.py b/server/src/wled_controller/core/processing/effect_stream.py index dcc1db6..466cc23 100644 --- a/server/src/wled_controller/core/processing/effect_stream.py +++ b/server/src/wled_controller/core/processing/effect_stream.py @@ -286,8 +286,9 @@ class EffectColorStripStream(ColorStripStream): ) self._custom_palette = getattr(source, "custom_palette", None) self._resolve_palette_lut() - color = getattr(source, "color", None) - self._color = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0] + from wled_controller.storage.bindable import bcolor + + self._color = bcolor(getattr(source, "color", None), [255, 80, 0]) from wled_controller.storage.bindable import bfloat self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0) @@ -490,7 +491,7 @@ class EffectColorStripStream(ColorStripStream): """Bright meteor head with exponential-decay trail.""" speed = self._effective_speed intensity = self.resolve("intensity", self._intensity) - color = self._color + color = self.resolve_color("color", self._color) mirror = self._mirror # Compute position along the strip @@ -686,7 +687,7 @@ class EffectColorStripStream(ColorStripStream): """Multiple comets with curved, pulsing tails.""" speed = self._effective_speed intensity = self.resolve("intensity", self._intensity) - color = self._color + color = self.resolve_color("color", self._color) mirror = self._mirror indices = self._s_arange @@ -732,7 +733,7 @@ class EffectColorStripStream(ColorStripStream): """Physics-simulated bouncing balls with gravity.""" speed = self._effective_speed intensity = self.resolve("intensity", self._intensity) - color = self._color + color = self.resolve_color("color", self._color) num_balls = 3 # Initialize ball state on first call or LED count change diff --git a/server/src/wled_controller/core/processing/notification_stream.py b/server/src/wled_controller/core/processing/notification_stream.py index 8ee18d7..94aee0f 100644 --- a/server/src/wled_controller/core/processing/notification_stream.py +++ b/server/src/wled_controller/core/processing/notification_stream.py @@ -76,7 +76,9 @@ class NotificationColorStripStream(ColorStripStream): """Parse config from source dataclass.""" self._notification_effect = getattr(source, "notification_effect", "flash") self._duration_ms = max(100, int(bfloat(getattr(source, "duration_ms", 1500), 1500))) - self._default_color = getattr(source, "default_color", "#FFFFFF") + from wled_controller.storage.bindable import bcolor + + self._default_color = bcolor(getattr(source, "default_color", None), [255, 255, 255]) self._app_colors = { k.lower(): v for k, v in dict(getattr(source, "app_colors", {})).items() } @@ -121,7 +123,7 @@ class NotificationColorStripStream(ColorStripStream): elif app_lower and app_lower in self._app_colors: color = _hex_to_rgb(self._app_colors[app_lower]) else: - color = _hex_to_rgb(self._default_color) + color = self.resolve_color("default_color", self._default_color) # Push event to queue (thread-safe deque.append) # Priority: 0 = normal, 1 = high (high interrupts current effect) diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index c09ab63..5aa7eb1 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -159,6 +159,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) audio_source_store=deps.audio_source_store, live_stream_manager=self._live_stream_manager, audio_template_store=deps.audio_template_store, + ha_manager=deps.ha_manager, + css_stream_manager=self._color_strip_stream_manager, + gradient_store=deps.gradient_store, ) if deps.value_source_store else None diff --git a/server/src/wled_controller/core/processing/value_stream.py b/server/src/wled_controller/core/processing/value_stream.py index 31f5a94..1066a9b 100644 --- a/server/src/wled_controller/core/processing/value_stream.py +++ b/server/src/wled_controller/core/processing/value_stream.py @@ -24,7 +24,7 @@ import math import time from abc import ABC, abstractmethod from datetime import datetime -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple import numpy as np @@ -32,6 +32,8 @@ from wled_controller.utils import get_logger if TYPE_CHECKING: from wled_controller.core.audio.audio_capture import AudioCaptureManager + from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager + from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager from wled_controller.core.processing.live_stream_manager import LiveStreamManager from wled_controller.storage.audio_source_store import AudioSourceStore from wled_controller.storage.value_source import ValueSource @@ -44,6 +46,7 @@ logger = get_logger(__name__) # Base class # --------------------------------------------------------------------------- + class ValueStream(ABC): """Abstract base for runtime value streams.""" @@ -52,6 +55,10 @@ class ValueStream(ABC): """Return current scalar value (0.0–1.0).""" ... + def get_color(self) -> tuple: + """Return current RGB color as (R, G, B). Override in color streams.""" + raise NotImplementedError("This stream does not produce colors") + def start(self) -> None: """Acquire resources (if any).""" @@ -66,6 +73,7 @@ class ValueStream(ABC): # Static # --------------------------------------------------------------------------- + class StaticValueStream(ValueStream): """Returns a constant float.""" @@ -77,6 +85,7 @@ class StaticValueStream(ValueStream): def update_source(self, source: "ValueSource") -> None: from wled_controller.storage.value_source import StaticValueSource + if isinstance(source, StaticValueSource): self._value = max(0.0, min(1.0, source.value)) @@ -133,6 +142,7 @@ class AnimatedValueStream(ValueStream): def update_source(self, source: "ValueSource") -> None: from wled_controller.storage.value_source import AnimatedValueSource + if isinstance(source, AnimatedValueSource): self._waveform = source.waveform self._speed = source.speed @@ -144,6 +154,7 @@ class AnimatedValueStream(ValueStream): # Audio # --------------------------------------------------------------------------- + class AudioValueStream(ValueStream): """Polls audio analysis for a scalar value. @@ -173,8 +184,8 @@ class AudioValueStream(ValueStream): self._min = min_value self._max = max_value self._auto_gain = auto_gain - self._rolling_peak = 0.0 # tracks observed max raw audio value - self._rolling_decay = 0.995 # slow decay (~5-10s adaptation) + self._rolling_peak = 0.0 # tracks observed max raw audio value + self._rolling_decay = 0.995 # slow decay (~5-10s adaptation) self._audio_capture_manager = audio_capture_manager self._audio_source_store = audio_source_store self._audio_template_store = audio_template_store @@ -208,7 +219,11 @@ class AudioValueStream(ValueStream): self._audio_engine_type = tpl.engine_type self._audio_engine_config = tpl.engine_config except ValueError as e: - logger.warning("Audio template %s not found for value stream, using default engine: %s", template_id, e) + logger.warning( + "Audio template %s not found for value stream, using default engine: %s", + template_id, + e, + ) pass except ValueError as e: logger.warning(f"Failed to resolve audio source {self._audio_source_id}: {e}") @@ -217,7 +232,8 @@ class AudioValueStream(ValueStream): if self._audio_capture_manager is None: return self._audio_stream = self._audio_capture_manager.acquire( - self._audio_device_index, self._audio_loopback, + self._audio_device_index, + self._audio_loopback, engine_type=self._audio_engine_type, engine_config=self._audio_engine_config, ) @@ -229,7 +245,8 @@ class AudioValueStream(ValueStream): def stop(self) -> None: if self._audio_stream is not None and self._audio_capture_manager is not None: self._audio_capture_manager.release( - self._audio_device_index, self._audio_loopback, + self._audio_device_index, + self._audio_loopback, engine_type=self._audio_engine_type, ) self._audio_stream = None @@ -295,6 +312,7 @@ class AudioValueStream(ValueStream): def update_source(self, source: "ValueSource") -> None: from wled_controller.storage.value_source import AudioValueSource + if not isinstance(source, AudioValueSource): return @@ -320,10 +338,13 @@ class AudioValueStream(ValueStream): self._resolve_audio_source() if self._audio_stream is not None and self._audio_capture_manager is not None: self._audio_capture_manager.release( - old_device, old_loopback, engine_type=old_engine_type, + old_device, + old_loopback, + engine_type=old_engine_type, ) self._audio_stream = self._audio_capture_manager.acquire( - self._audio_device_index, self._audio_loopback, + self._audio_device_index, + self._audio_loopback, engine_type=self._audio_engine_type, engine_config=self._audio_engine_config, ) @@ -403,7 +424,9 @@ class TimeOfDayValueStream(ValueStream): elapsed = current - t_left else: interval = (_MINUTES_PER_DAY - t_left) + t_right - elapsed = (current - t_left) if current >= t_left else (_MINUTES_PER_DAY - t_left) + current + elapsed = ( + (current - t_left) if current >= t_left else (_MINUTES_PER_DAY - t_left) + current + ) frac = elapsed / interval if interval > 0 else 0.0 raw = v_left + frac * (v_right - v_left) @@ -413,6 +436,7 @@ class TimeOfDayValueStream(ValueStream): def update_source(self, source: "ValueSource") -> None: from wled_controller.storage.value_source import AdaptiveValueSource + if isinstance(source, AdaptiveValueSource) and source.source_type == "adaptive_time": self._parse_schedule(source.schedule) self._min = source.min_value @@ -423,6 +447,7 @@ class TimeOfDayValueStream(ValueStream): # Scene # --------------------------------------------------------------------------- + class SceneValueStream(ValueStream): """Derives brightness from a picture source's average frame luminance. @@ -458,12 +483,8 @@ class SceneValueStream(ValueStream): def start(self) -> None: if self._live_stream_manager and self._picture_source_id: try: - self._live_stream = self._live_stream_manager.acquire( - self._picture_source_id - ) - logger.info( - f"SceneValueStream acquired live stream for {self._picture_source_id}" - ) + self._live_stream = self._live_stream_manager.acquire(self._picture_source_id) + logger.info(f"SceneValueStream acquired live stream for {self._picture_source_id}") except Exception as e: logger.warning(f"SceneValueStream failed to acquire live stream: {e}") self._live_stream = None @@ -493,9 +514,14 @@ class SceneValueStream(ValueStream): sampled = img[::step_h, ::step_w].astype(np.float32) # BT.601 weighted luminance, normalized to [0, 1] - luminance = float( - (0.299 * sampled[:, :, 0] + 0.587 * sampled[:, :, 1] + 0.114 * sampled[:, :, 2]).mean() - ) / 255.0 + luminance = ( + float( + ( + 0.299 * sampled[:, :, 0] + 0.587 * sampled[:, :, 1] + 0.114 * sampled[:, :, 2] + ).mean() + ) + / 255.0 + ) # Apply sensitivity raw = min(1.0, luminance * self._sensitivity) @@ -517,6 +543,7 @@ class SceneValueStream(ValueStream): def update_source(self, source: "ValueSource") -> None: from wled_controller.storage.value_source import AdaptiveValueSource + if not isinstance(source, AdaptiveValueSource) or source.source_type != "adaptive_scene": return @@ -533,9 +560,7 @@ class SceneValueStream(ValueStream): if self._live_stream is not None and self._live_stream_manager: self._live_stream_manager.release(old_id) try: - self._live_stream = self._live_stream_manager.acquire( - self._picture_source_id - ) + self._live_stream = self._live_stream_manager.acquire(self._picture_source_id) logger.info( f"SceneValueStream swapped live stream: {old_id} → " f"{self._picture_source_id}" @@ -567,6 +592,7 @@ class DaylightValueStream(ValueStream): max_value: float = 1.0, ): from wled_controller.core.processing.daylight_stream import _get_daylight_lut + self._lut = _get_daylight_lut() self._speed = speed self._use_real_time = use_real_time @@ -595,6 +621,7 @@ class DaylightValueStream(ValueStream): def update_source(self, source: "ValueSource") -> None: from wled_controller.storage.value_source import DaylightValueSource + if isinstance(source, DaylightValueSource): self._speed = source.speed self._use_real_time = source.use_real_time @@ -603,10 +630,500 @@ class DaylightValueStream(ValueStream): self._max = source.max_value +# --------------------------------------------------------------------------- +# Color streams +# --------------------------------------------------------------------------- + + +class StaticColorValueStream(ValueStream): + """Returns a constant RGB color.""" + + def __init__(self, color): + c = color if isinstance(color, (list, tuple)) and len(color) == 3 else [255, 255, 255] + self._color = (int(c[0]), int(c[1]), int(c[2])) + + def get_value(self) -> float: + r, g, b = self._color + return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0 + + def get_color(self) -> tuple: + return self._color + + def update_source(self, source) -> None: + c = getattr(source, "color", [255, 255, 255]) + self._color = ( + (int(c[0]), int(c[1]), int(c[2])) + if isinstance(c, (list, tuple)) and len(c) == 3 + else (255, 255, 255) + ) + + +class AnimatedColorValueStream(ValueStream): + """Cycles through a list of colors over time.""" + + def __init__(self, colors, speed=10.0, easing="linear"): + self._colors = [ + (int(c[0]), int(c[1]), int(c[2])) + for c in (colors or [[255, 0, 0], [0, 255, 0], [0, 0, 255]]) + if isinstance(c, (list, tuple)) and len(c) == 3 + ] or [(255, 0, 0), (0, 255, 0), (0, 0, 255)] + self._speed = max(0.01, float(speed)) + self._easing = easing + self._start_time = 0.0 + + def start(self) -> None: + self._start_time = time.monotonic() + + def get_value(self) -> float: + r, g, b = self.get_color() + return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0 + + def get_color(self) -> tuple: + elapsed = time.monotonic() - self._start_time + cycle_time = 60.0 / self._speed + n = len(self._colors) + if self._easing == "step": + idx = int((elapsed / cycle_time * n) % n) + return self._colors[idx] + phase = (elapsed / cycle_time * n) % n + idx = int(phase) + frac = phase - idx + c1 = self._colors[idx % n] + c2 = self._colors[(idx + 1) % n] + return ( + int(c1[0] + (c2[0] - c1[0]) * frac), + int(c1[1] + (c2[1] - c1[1]) * frac), + int(c1[2] + (c2[2] - c1[2]) * frac), + ) + + def update_source(self, source) -> None: + raw = getattr(source, "colors", None) + if raw and isinstance(raw, list): + self._colors = [ + (int(c[0]), int(c[1]), int(c[2])) + for c in raw + if isinstance(c, (list, tuple)) and len(c) == 3 + ] or self._colors + self._speed = max(0.01, float(getattr(source, "speed", self._speed))) + self._easing = getattr(source, "easing", self._easing) + + +class AdaptiveTimeColorValueStream(ValueStream): + """Interpolates RGB colors along a 24-hour schedule.""" + + def __init__(self, schedule): + self._schedule = schedule or [] + self._parsed = self._parse(self._schedule) + + @staticmethod + def _parse(schedule): + result = [] + for entry in schedule: + t_str = entry.get("time", "00:00") + parts = t_str.split(":") + minutes = int(parts[0]) * 60 + int(parts[1]) if len(parts) >= 2 else 0 + c = entry.get("color", [255, 255, 255]) + color = ( + (int(c[0]), int(c[1]), int(c[2])) + if isinstance(c, (list, tuple)) and len(c) == 3 + else (255, 255, 255) + ) + result.append((minutes, color)) + result.sort(key=lambda x: x[0]) + return result + + def get_value(self) -> float: + r, g, b = self.get_color() + return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0 + + def get_color(self) -> tuple: + from datetime import datetime + + now = datetime.now() + cur = now.hour * 60 + now.minute + now.second / 60.0 + pts = self._parsed + if not pts: + return (255, 255, 255) + if len(pts) == 1: + return pts[0][1] + before = pts[-1] + after = pts[0] + for i, (t, c) in enumerate(pts): + if t <= cur: + before = (t, c) + after = pts[(i + 1) % len(pts)] + b_t, b_c = before + a_t, a_c = after + span = a_t - b_t + if span <= 0: + span += 1440 + elapsed = cur - b_t + if elapsed < 0: + elapsed += 1440 + frac = max(0.0, min(1.0, elapsed / span if span > 0 else 0.0)) + return ( + int(b_c[0] + (a_c[0] - b_c[0]) * frac), + int(b_c[1] + (a_c[1] - b_c[1]) * frac), + int(b_c[2] + (a_c[2] - b_c[2]) * frac), + ) + + def update_source(self, source) -> None: + self._schedule = getattr(source, "schedule", self._schedule) + self._parsed = self._parse(self._schedule) + + +# --------------------------------------------------------------------------- +# HA Entity +# --------------------------------------------------------------------------- + + +class HAEntityValueStream(ValueStream): + """Reads a numeric value from a Home Assistant entity state or attribute. + + Normalizes the raw HA value to [0, 1] using the configured min/max range, + applies EMA smoothing. + """ + + def __init__( + self, + ha_source_id: str, + entity_id: str, + attribute: str = "", + min_ha_value: float = 0.0, + max_ha_value: float = 100.0, + smoothing: float = 0.0, + ha_manager: Optional[Any] = None, + ): + self._ha_source_id = ha_source_id + self._entity_id = entity_id + self._attribute = attribute + self._min_ha = min_ha_value + self._max_ha = max_ha_value + self._smoothing = smoothing + self._ha_manager = ha_manager + self._prev_value: Optional[float] = None + + def start(self) -> None: + if self._ha_manager and self._ha_source_id: + try: + self._ha_manager.acquire(self._ha_source_id) + logger.info( + "HAEntityValueStream started (ha=%s, entity=%s, attr=%s)", + self._ha_source_id, + self._entity_id, + self._attribute or "", + ) + except Exception as e: + logger.warning("HAEntityValueStream failed to acquire HA runtime: %s", e) + + def stop(self) -> None: + if self._ha_manager and self._ha_source_id: + try: + self._ha_manager.release(self._ha_source_id) + except Exception as e: + logger.warning("HAEntityValueStream failed to release HA runtime: %s", e) + self._prev_value = None + + def get_value(self) -> float: + if self._ha_manager is None: + return self._prev_value if self._prev_value is not None else 0.0 + + state = self._ha_manager.get_state(self._ha_source_id, self._entity_id) + if state is None: + return self._prev_value if self._prev_value is not None else 0.0 + + # Extract raw value from state or attribute + try: + if self._attribute: + attrs = getattr(state, "attributes", {}) + raw_str = attrs.get(self._attribute, getattr(state, "state", "0")) + else: + raw_str = getattr(state, "state", "0") + raw = float(raw_str) + except (ValueError, TypeError): + return self._prev_value if self._prev_value is not None else 0.0 + + # Normalize to [0, 1] + ha_range = self._max_ha - self._min_ha + if abs(ha_range) < 1e-9: + normalized = 0.5 + else: + normalized = (raw - self._min_ha) / ha_range + + normalized = max(0.0, min(1.0, normalized)) + + # EMA smoothing + if self._smoothing > 0.0 and self._prev_value is not None: + normalized = self._smoothing * self._prev_value + (1.0 - self._smoothing) * normalized + + self._prev_value = normalized + return normalized + + def update_source(self, source: "ValueSource") -> None: + from wled_controller.storage.value_source import HAEntityValueSource + + if not isinstance(source, HAEntityValueSource): + return + + old_ha_source = self._ha_source_id + self._ha_source_id = source.ha_source_id + self._entity_id = source.entity_id + self._attribute = source.attribute + self._min_ha = source.min_ha_value + self._max_ha = source.max_ha_value + self._smoothing = source.smoothing + + # If HA source changed, swap runtime + if source.ha_source_id != old_ha_source and self._ha_manager: + try: + self._ha_manager.release(old_ha_source) + self._ha_manager.acquire(self._ha_source_id) + except Exception as e: + logger.warning("HAEntityValueStream failed to swap HA runtime: %s", e) + + +# --------------------------------------------------------------------------- +# Gradient Map +# --------------------------------------------------------------------------- + + +class GradientMapValueStream(ValueStream): + """Maps a float value source through a color gradient. + + Acquires a float-returning ValueStream, reads its value (0..1), and + interpolates the color at that position in the gradient stops resolved + from a gradient entity. + """ + + def __init__( + self, + value_source_id: str, + gradient_id: str = "", + easing: str = "linear", + value_stream_manager: Optional["ValueStreamManager"] = None, + gradient_store: Optional[Any] = None, + ): + self._value_source_id = value_source_id + self._gradient_id = gradient_id + self._easing = easing + self._vsm = value_stream_manager + self._gradient_store = gradient_store + self._inner_stream: Optional[ValueStream] = None + self._stops: list = [] + self._resolve_gradient() + + def _resolve_gradient(self) -> None: + """Resolve gradient_id to stops list.""" + if self._gradient_id and self._gradient_store: + try: + gradient = self._gradient_store.get(self._gradient_id) + self._stops = sorted( + gradient.stops if hasattr(gradient, "stops") else [], + key=lambda s: ( + s.get("position", 0) if isinstance(s, dict) else getattr(s, "position", 0) + ), + ) + except Exception as e: + logger.warning( + "GradientMapValueStream failed to resolve gradient %s: %s", self._gradient_id, e + ) + self._stops = [] + else: + self._stops = [] + + def start(self) -> None: + if self._vsm and self._value_source_id: + try: + self._inner_stream = self._vsm.acquire(self._value_source_id) + logger.info( + "GradientMapValueStream acquired inner stream %s", self._value_source_id + ) + except Exception as e: + logger.warning("GradientMapValueStream failed to acquire inner stream: %s", e) + + def stop(self) -> None: + if self._vsm and self._value_source_id: + self._vsm.release(self._value_source_id) + self._inner_stream = None + + def get_value(self) -> float: + r, g, b = self.get_color() + return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0 + + def get_color(self) -> tuple: + if self._inner_stream is None: + return (128, 128, 128) + + t = max(0.0, min(1.0, self._inner_stream.get_value())) + stops = self._stops + if not stops: + return (128, 128, 128) + if len(stops) == 1: + c = stops[0].get("color", [128, 128, 128]) + return (int(c[0]), int(c[1]), int(c[2])) + + # Find surrounding stops + left = stops[0] + right = stops[-1] + for i in range(len(stops) - 1): + if stops[i].get("position", 0) <= t <= stops[i + 1].get("position", 1): + left = stops[i] + right = stops[i + 1] + break + + lp = left.get("position", 0) + rp = right.get("position", 1) + lc = left.get("color", [0, 0, 0]) + rc = right.get("color", [255, 255, 255]) + + span = rp - lp + if span <= 0 or self._easing == "step": + return (int(lc[0]), int(lc[1]), int(lc[2])) + + frac = (t - lp) / span + return ( + int(lc[0] + (rc[0] - lc[0]) * frac), + int(lc[1] + (rc[1] - lc[1]) * frac), + int(lc[2] + (rc[2] - lc[2]) * frac), + ) + + def update_source(self, source: "ValueSource") -> None: + from wled_controller.storage.value_source import GradientMapValueSource + + if not isinstance(source, GradientMapValueSource): + return + + self._gradient_id = source.gradient_id + self._easing = source.easing + self._resolve_gradient() + + # If input source changed, swap + if source.value_source_id != self._value_source_id and self._vsm: + old_id = self._value_source_id + self._value_source_id = source.value_source_id + if self._inner_stream is not None: + self._vsm.release(old_id) + try: + self._inner_stream = self._vsm.acquire(self._value_source_id) + except Exception as e: + logger.warning("GradientMapValueStream failed to swap inner stream: %s", e) + self._inner_stream = None + + +# --------------------------------------------------------------------------- +# CSS Extract +# --------------------------------------------------------------------------- + + +class CSSExtractValueStream(ValueStream): + """Extracts a single color by averaging a LED range from a color strip stream. + + Acquires a ColorStripStream, reads the LED colors, and averages the + specified range to produce a single RGB color. + """ + + _DEFAULT_LED_COUNT = 60 + + def __init__( + self, + color_strip_source_id: str, + led_start: int = 0, + led_end: int = -1, + css_stream_manager: Optional["ColorStripStreamManager"] = None, + ): + self._css_source_id = color_strip_source_id + self._led_start = led_start + self._led_end = led_end + self._cssm = css_stream_manager + self._css_stream = None + + def start(self) -> None: + if self._cssm and self._css_source_id: + try: + self._css_stream = self._cssm.acquire( + self._css_source_id, + self._DEFAULT_LED_COUNT, + ) + logger.info("CSSExtractValueStream acquired CSS stream %s", self._css_source_id) + except Exception as e: + logger.warning("CSSExtractValueStream failed to acquire CSS stream: %s", e) + + def stop(self) -> None: + if self._cssm and self._css_source_id: + try: + self._cssm.release(self._css_source_id, self._DEFAULT_LED_COUNT) + except Exception as e: + logger.warning("CSSExtractValueStream failed to release CSS stream: %s", e) + self._css_stream = None + + def get_value(self) -> float: + r, g, b = self.get_color() + return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0 + + def get_color(self) -> tuple: + if self._css_stream is None: + return (0, 0, 0) + + colors = self._css_stream.get_colors() + if colors is None or len(colors) == 0: + return (0, 0, 0) + + n = len(colors) + start = max(0, self._led_start) + end = self._led_end if self._led_end >= 0 else n + end = min(end, n) + + if start >= end: + return (0, 0, 0) + + segment = colors[start:end] + if len(segment) == 0: + return (0, 0, 0) + + # Average the segment — colors is numpy array (N, 3) uint8 + if isinstance(segment, np.ndarray): + avg = segment.mean(axis=0) + return (int(avg[0]), int(avg[1]), int(avg[2])) + + # Fallback for list + r = sum(c[0] for c in segment) // len(segment) + g = sum(c[1] for c in segment) // len(segment) + b = sum(c[2] for c in segment) // len(segment) + return (r, g, b) + + def update_source(self, source: "ValueSource") -> None: + from wled_controller.storage.value_source import CSSExtractValueSource + + if not isinstance(source, CSSExtractValueSource): + return + + self._led_start = source.led_start + self._led_end = source.led_end + + # If CSS source changed, swap + if source.color_strip_source_id != self._css_source_id and self._cssm: + old_id = self._css_source_id + self._css_source_id = source.color_strip_source_id + if self._css_stream is not None: + try: + self._cssm.release(old_id, self._DEFAULT_LED_COUNT) + except Exception: + pass + try: + self._css_stream = self._cssm.acquire( + self._css_source_id, + self._DEFAULT_LED_COUNT, + ) + except Exception as e: + logger.warning("CSSExtractValueStream failed to swap CSS stream: %s", e) + self._css_stream = None + + # --------------------------------------------------------------------------- # Manager # --------------------------------------------------------------------------- + class ValueStreamManager: """Owns running ValueStream instances, shared and ref-counted by vs_id. @@ -623,14 +1140,20 @@ class ValueStreamManager: audio_source_store: Optional["AudioSourceStore"] = None, live_stream_manager: Optional["LiveStreamManager"] = None, audio_template_store=None, + ha_manager: Optional["HomeAssistantManager"] = None, + css_stream_manager: Optional["ColorStripStreamManager"] = None, + gradient_store: Optional[Any] = None, ): self._value_source_store = value_source_store self._audio_capture_manager = audio_capture_manager self._audio_source_store = audio_source_store self._live_stream_manager = live_stream_manager self._audio_template_store = audio_template_store - self._streams: Dict[str, ValueStream] = {} # vs_id → stream - self._ref_counts: Dict[str, int] = {} # vs_id → ref count + self._ha_manager = ha_manager + self._css_stream_manager = css_stream_manager + self._gradient_store = gradient_store + self._streams: Dict[str, ValueStream] = {} # vs_id → stream + self._ref_counts: Dict[str, int] = {} # vs_id → ref count def acquire(self, vs_id: str) -> ValueStream: """Get or create a shared ValueStream for the given ValueSource. @@ -698,8 +1221,14 @@ class ValueStreamManager: AdaptiveValueSource, AnimatedValueSource, AudioValueSource, + CSSExtractValueSource, DaylightValueSource, + GradientMapValueSource, + HAEntityValueSource, StaticValueSource, + StaticColorValueSource, + AnimatedColorValueSource, + AdaptiveTimeColorValueSource, ) if isinstance(source, StaticValueSource): @@ -753,5 +1282,47 @@ class ValueStreamManager: max_value=source.max_value, ) + # Color streams + if isinstance(source, StaticColorValueSource): + return StaticColorValueStream(color=source.color) + + if isinstance(source, AnimatedColorValueSource): + return AnimatedColorValueStream( + colors=source.colors, + speed=source.speed, + easing=source.easing, + ) + + if isinstance(source, AdaptiveTimeColorValueSource): + return AdaptiveTimeColorValueStream(schedule=source.schedule) + + if isinstance(source, HAEntityValueSource): + return HAEntityValueStream( + ha_source_id=source.ha_source_id, + entity_id=source.entity_id, + attribute=source.attribute, + min_ha_value=source.min_ha_value, + max_ha_value=source.max_ha_value, + smoothing=source.smoothing, + ha_manager=self._ha_manager, + ) + + if isinstance(source, GradientMapValueSource): + return GradientMapValueStream( + value_source_id=source.value_source_id, + gradient_id=source.gradient_id, + easing=source.easing, + value_stream_manager=self, + gradient_store=self._gradient_store, + ) + + if isinstance(source, CSSExtractValueSource): + return CSSExtractValueStream( + color_strip_source_id=source.color_strip_source_id, + led_start=source.led_start, + led_end=source.led_end, + css_stream_manager=self._css_stream_manager, + ) + # Fallback return StaticValueStream(value=1.0) diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index 62f48bf..c1c6ee1 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -845,6 +845,32 @@ textarea:focus-visible { pointer-events: none; } +.type-picker-tabs { + display: flex; + gap: 4px; + margin-bottom: 8px; +} +.type-picker-tab { + flex: 1; + padding: 6px 12px; + border: 1px solid var(--border-color, #333); + border-radius: 6px; + background: var(--surface-2, #1e1e2e); + color: var(--text-secondary, #999); + font-size: 0.8rem; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.type-picker-tab:hover { + background: var(--surface-3, #2a2a3e); + color: var(--text-primary, #e0e0e0); +} +.type-picker-tab.active { + background: var(--primary-color, #63b3ed); + color: #fff; + border-color: var(--primary-color, #63b3ed); +} + /* ── Entity Palette (command-palette style selector) ─────── */ .entity-palette-overlay { diff --git a/server/src/wled_controller/static/css/dashboard.css b/server/src/wled_controller/static/css/dashboard.css index 4111561..851c849 100644 --- a/server/src/wled_controller/static/css/dashboard.css +++ b/server/src/wled_controller/static/css/dashboard.css @@ -14,9 +14,16 @@ gap: 6px; text-transform: uppercase; letter-spacing: 0.5px; - cursor: pointer; user-select: none; } +.dashboard-section-toggle { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + flex: 1; + min-width: 0; +} .dashboard-section-chevron { font-size: 0.6rem; @@ -453,13 +460,12 @@ left: 12px; font-size: 0.6rem; font-weight: 400; - color: var(--text-muted); + color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: calc(100% - 4px); pointer-events: none; - opacity: 0.7; z-index: 1; } @@ -480,9 +486,12 @@ font-weight: 500; color: var(--text-secondary); background: var(--hover-bg); - padding: 1px 5px; + padding: 2px 5px; border-radius: 3px; letter-spacing: 0.2px; + vertical-align: middle; + display: inline-flex; + align-items: center; } .perf-chart-label .color-picker-swatch { diff --git a/server/src/wled_controller/static/js/app.ts b/server/src/wled_controller/static/js/app.ts index 418e158..c24fe6d 100644 --- a/server/src/wled_controller/static/js/app.ts +++ b/server/src/wled_controller/static/js/app.ts @@ -148,6 +148,8 @@ import { editValueSource, cloneValueSource, deleteValueSource, onValueSourceTypeChange, onDaylightVSRealTimeChange, addSchedulePoint, + addAnimatedColor, removeAnimatedColor, + addColorSchedulePoint, removeColorSchedulePoint, testValueSource, closeTestValueSourceModal, } from './features/value-sources.ts'; @@ -468,6 +470,10 @@ Object.assign(window, { onValueSourceTypeChange, onDaylightVSRealTimeChange, addSchedulePoint, + addAnimatedColor, + removeAnimatedColor, + addColorSchedulePoint, + removeColorSchedulePoint, testValueSource, closeTestValueSourceModal, diff --git a/server/src/wled_controller/static/js/core/api.ts b/server/src/wled_controller/static/js/core/api.ts index 5b051f1..8346058 100644 --- a/server/src/wled_controller/static/js/core/api.ts +++ b/server/src/wled_controller/static/js/core/api.ts @@ -71,11 +71,14 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P await new Promise(r => setTimeout(r, 500 * 2 ** attempt)); continue; } - // Final attempt failed — show user-facing error - const errMsg = (err as Error)?.name === 'AbortError' - ? t('api.error.timeout') - : t('api.error.network'); - showToast(errMsg, 'error'); + // Final attempt failed — show toast only if the connection + // overlay isn't already covering the screen + if (_serverOnline !== false) { + const errMsg = (err as Error)?.name === 'AbortError' + ? t('api.error.timeout') + : t('api.error.network'); + showToast(errMsg, 'error'); + } throw err; } } diff --git a/server/src/wled_controller/static/js/core/bindable-color.ts b/server/src/wled_controller/static/js/core/bindable-color.ts new file mode 100644 index 0000000..067e708 --- /dev/null +++ b/server/src/wled_controller/static/js/core/bindable-color.ts @@ -0,0 +1,168 @@ +/** + * BindableColorWidget — a color picker that can optionally bind to a color value source. + * + * Renders a color input with a small toggle button. When toggled to + * "bound" mode, shows an EntitySelect color value source picker. + * Emits a BindableColor value: plain [R,G,B] (static) or {color, source_id} (bound). + */ + +import type { BindableColor } from '../types.ts'; +import { bindableColor, bindableColorSourceId } from '../types.ts'; +import { EntitySelect } from './entity-palette.ts'; +import { getValueSourceIcon } from './icons.ts'; +import { t } from './i18n.ts'; + +export interface BindableColorOpts { + container: HTMLElement; + default: number[]; + /** Only show color value sources (return_type="color") */ + valueSources: () => Array<{ id: string; name: string; source_type: string; return_type?: string }>; + onChange?: (value: BindableColor) => void; + idPrefix?: string; + noneLabel?: string; +} + +let _widgetCounter = 0; + +export class BindableColorWidget { + private _container: HTMLElement; + private _opts: BindableColorOpts; + private _id: string; + private _bound: boolean = false; + private _staticColor: number[]; + private _sourceId: string = ''; + private _entitySelect: EntitySelect | null = null; + + private _colorRow!: HTMLElement; + private _vsRow!: HTMLElement; + private _colorInput!: HTMLInputElement; + private _toggleBtn!: HTMLButtonElement; + private _select!: HTMLSelectElement; + + constructor(opts: BindableColorOpts) { + this._opts = opts; + this._container = opts.container; + this._staticColor = [...opts.default]; + this._id = opts.idPrefix || `bcw-${++_widgetCounter}`; + this._render(); + } + + private _rgbToHex(rgb: number[]): string { + return '#' + rgb.map(c => Math.max(0, Math.min(255, c)).toString(16).padStart(2, '0')).join(''); + } + + private _hexToRgb(hex: string): number[] { + const m = hex.replace('#', '').match(/.{2}/g); + return m ? m.map(h => parseInt(h, 16)) : [255, 255, 255]; + } + + private _render(): void { + const id = this._id; + const hex = this._rgbToHex(this._staticColor); + + const toggleSvg = ``; + + const colorHtml = `
+ + +
`; + + const vsHtml = ``; + + this._container.innerHTML = colorHtml + vsHtml; + + this._colorRow = document.getElementById(`${id}-color-row`)!; + this._vsRow = document.getElementById(`${id}-vs-row`)!; + this._colorInput = document.getElementById(`${id}-color`) as HTMLInputElement; + this._select = document.getElementById(`${id}-select`) as HTMLSelectElement; + this._toggleBtn = document.getElementById(`${id}-toggle`) as HTMLButtonElement; + + this._colorInput.addEventListener('input', () => { + this._staticColor = this._hexToRgb(this._colorInput.value); + this._fireChange(); + }); + + this._toggleBtn.addEventListener('click', () => this._setMode(true)); + document.getElementById(`${id}-untoggle`)!.addEventListener('click', () => this._setMode(false)); + } + + private _setMode(bound: boolean): void { + this._bound = bound; + this._colorRow.style.display = bound ? 'none' : ''; + this._vsRow.style.display = bound ? '' : 'none'; + if (bound) { + this._populateVsSelect(); + } else { + this._sourceId = ''; + if (this._entitySelect) { this._entitySelect.destroy(); this._entitySelect = null; } + } + this._fireChange(); + } + + private _populateVsSelect(): void { + // Filter to only color value sources + const sources = this._opts.valueSources().filter(vs => vs.return_type === 'color'); + + this._select.innerHTML = `` + + sources.map(vs => + `` + ).join(''); + + if (this._entitySelect) this._entitySelect.destroy(); + this._entitySelect = new EntitySelect({ + target: this._select, + getItems: () => sources.map(vs => ({ + value: vs.id, + label: vs.name, + icon: getValueSourceIcon(vs.source_type), + desc: vs.source_type, + })), + placeholder: t('palette.search'), + allowNone: true, + noneLabel: t('bindable.none'), + onChange: (value: string) => { + this._sourceId = value; + this._fireChange(); + }, + }); + } + + private _fireChange(): void { + if (this._opts.onChange) { + this._opts.onChange(this.getValue()); + } + } + + getValue(): BindableColor { + if (this._bound && this._sourceId) { + return { color: [...this._staticColor], source_id: this._sourceId }; + } + return [...this._staticColor]; + } + + setValue(bc: BindableColor | undefined): void { + this._staticColor = bindableColor(bc, this._opts.default); + this._sourceId = bindableColorSourceId(bc); + this._bound = !!this._sourceId; + + this._colorInput.value = this._rgbToHex(this._staticColor); + this._colorRow.style.display = this._bound ? 'none' : ''; + this._vsRow.style.display = this._bound ? '' : 'none'; + + if (this._bound) { + this._populateVsSelect(); + } + } + + refresh(): void { + if (this._bound) this._populateVsSelect(); + } + + destroy(): void { + if (this._entitySelect) { this._entitySelect.destroy(); this._entitySelect = null; } + this._container.innerHTML = ''; + } +} diff --git a/server/src/wled_controller/static/js/core/graph-connections.ts b/server/src/wled_controller/static/js/core/graph-connections.ts index 3c73bf7..e0e4be6 100644 --- a/server/src/wled_controller/static/js/core/graph-connections.ts +++ b/server/src/wled_controller/static/js/core/graph-connections.ts @@ -48,6 +48,10 @@ const CONNECTION_MAP: ConnectionEntry[] = [ // Value sources { targetKind: 'value_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/value-sources/{id}', cache: valueSourcesCache }, { targetKind: 'value_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/value-sources/{id}', cache: valueSourcesCache }, + { targetKind: 'value_source', field: 'ha_source_id', sourceKind: 'ha_source', edgeType: 'ha', endpoint: '/value-sources/{id}', cache: valueSourcesCache }, + { targetKind: 'value_source', field: 'value_source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/value-sources/{id}', cache: valueSourcesCache }, + { targetKind: 'value_source', field: 'gradient_id', sourceKind: 'gradient', edgeType: 'gradient', endpoint: '/value-sources/{id}', cache: valueSourcesCache }, + { targetKind: 'value_source', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/value-sources/{id}', cache: valueSourcesCache }, // Color strip sources { targetKind: 'color_strip_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache }, @@ -77,6 +81,11 @@ const CONNECTION_MAP: ConnectionEntry[] = [ { targetKind: 'color_strip_source', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, // HA light target transition binding { targetKind: 'output_target', field: 'transition.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, + // ── BindableColor value source edges (CSS color properties) ── + { targetKind: 'color_strip_source', field: 'color.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, + { targetKind: 'color_strip_source', field: 'color_peak.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, + { targetKind: 'color_strip_source', field: 'fallback_color.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, + { targetKind: 'color_strip_source', field: 'default_color.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, // ── Nested fields (not drag-editable in V1) ── { targetKind: 'color_strip_source', field: 'layer.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true }, diff --git a/server/src/wled_controller/static/js/core/graph-layout.ts b/server/src/wled_controller/static/js/core/graph-layout.ts index 64511cd..3a7442a 100644 --- a/server/src/wled_controller/static/js/core/graph-layout.ts +++ b/server/src/wled_controller/static/js/core/graph-layout.ts @@ -359,6 +359,13 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[ const vsId = bindableSourceId((s as any)[prop]); if (vsId) addEdge(vsId, s.id, `${prop}.source_id`); } + // BindableColor value source edges + for (const prop of ['color', 'color_peak', 'fallback_color', 'default_color'] as const) { + const raw = (s as any)[prop]; + if (raw && typeof raw === 'object' && !Array.isArray(raw) && raw.source_id) { + addEdge(raw.source_id, s.id, `${prop}.source_id`); + } + } } // Output target edges diff --git a/server/src/wled_controller/static/js/core/icon-select.ts b/server/src/wled_controller/static/js/core/icon-select.ts index 51ef0f7..b463da2 100644 --- a/server/src/wled_controller/static/js/core/icon-select.ts +++ b/server/src/wled_controller/static/js/core/icon-select.ts @@ -287,17 +287,36 @@ export class IconSelect { * the overlay closes and `onPick(value)` is called. Clicking the backdrop * or pressing Escape dismisses without picking. */ -export function showTypePicker({ title, items, onPick }: { title: string; items: IconSelectItem[]; onPick: (value: string) => void }) { +export interface FilterTab { + key: string; + label: string; +} + +export function showTypePicker({ title, items, onPick, filterTabs, onFilterChange }: { + title: string; + items: IconSelectItem[]; + onPick: (value: string) => void; + filterTabs?: FilterTab[]; + onFilterChange?: (key: string) => IconSelectItem[]; +}) { const showFilter = items.length > 9; - // Build cells - const cells = items.map(item => - `
- ${item.icon} - ${item.label} - ${item.desc ? `${item.desc}` : ''} -
` - ).join(''); + function buildCells(cellItems: IconSelectItem[]): string { + return cellItems.map(item => + `
+ ${item.icon} + ${item.label} + ${item.desc ? `${item.desc}` : ''} +
` + ).join(''); + } + + // Build filter tabs HTML + const tabsHtml = filterTabs && filterTabs.length > 0 + ? `
${filterTabs.map((tab, i) => + `` + ).join('')}
` + : ''; // Create overlay const overlay = document.createElement('div'); @@ -305,26 +324,52 @@ export function showTypePicker({ title, items, onPick }: { title: string; items: overlay.innerHTML = `
${title}
+ ${tabsHtml} ${showFilter ? '' : ''} -
${cells}
+
${buildCells(items)}
`; document.body.appendChild(overlay); const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey); }; + const grid = overlay.querySelector('.icon-select-grid') as HTMLElement; + + function bindCellClicks() { + grid.querySelectorAll('.icon-select-cell').forEach(cell => { + cell.addEventListener('click', () => { + if (cell.classList.contains('disabled')) return; + close(); + onPick((cell as HTMLElement).dataset.value!); + }); + }); + } + bindCellClicks(); + + // Filter tabs logic + if (filterTabs && onFilterChange) { + overlay.querySelectorAll('.type-picker-tab').forEach(btn => { + btn.addEventListener('click', () => { + overlay.querySelectorAll('.type-picker-tab').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const key = (btn as HTMLElement).dataset.filterKey!; + const newItems = onFilterChange(key); + grid.innerHTML = buildCells(newItems); + bindCellClicks(); + }); + }); + } + // Filter logic if (showFilter) { const input = overlay.querySelector('.type-picker-filter') as HTMLInputElement; - const allCells = overlay.querySelectorAll('.icon-select-cell'); input.addEventListener('input', () => { const q = input.value.toLowerCase().trim(); - allCells.forEach(cell => { + grid.querySelectorAll('.icon-select-cell').forEach(cell => { const el = cell as HTMLElement; const match = !q || el.dataset.search!.includes(q); el.classList.toggle('disabled', !match); }); }); - // Auto-focus filter after animation (skip on touch devices to avoid keyboard popup) requestAnimationFrame(() => setTimeout(() => desktopFocus(input), 200)); } @@ -339,15 +384,6 @@ export function showTypePicker({ title, items, onPick }: { title: string; items: }; document.addEventListener('keydown', onKey); - // Cell clicks - overlay.querySelectorAll('.icon-select-cell').forEach(cell => { - cell.addEventListener('click', () => { - if (cell.classList.contains('disabled')) return; - close(); - onPick((cell as HTMLElement).dataset.value!); - }); - }); - // Animate in requestAnimationFrame(() => overlay.classList.add('open')); } diff --git a/server/src/wled_controller/static/js/core/icons.ts b/server/src/wled_controller/static/js/core/icons.ts index 3867960..09e1899 100644 --- a/server/src/wled_controller/static/js/core/icons.ts +++ b/server/src/wled_controller/static/js/core/icons.ts @@ -34,6 +34,10 @@ const _valueSourceTypeIcons = { static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music), adaptive_time: _svg(P.clock), adaptive_scene: _svg(P.cloudSun), daylight: _svg(P.sun), + static_color: _svg(P.palette), animated_color: _svg(P.refreshCw), + adaptive_time_color: _svg(P.clock), + ha_entity: _svg(P.home), gradient_map: _svg(P.rainbow), + css_extract: _svg(P.droplets), }; const _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume2), band_extract: _svg(P.activity) }; const _deviceTypeIcons = { @@ -298,6 +302,8 @@ export const ICON_TARGET_ICON = _svg(P.target); export const ICON_TRENDING_UP = _svg(P.trendingUp); export const ICON_ACTIVITY = _svg(P.activity); export const ICON_MOVE_VERTICAL = _svg(P.moveVertical); +export const ICON_HOME = _svg(P.home); +export const ICON_DROPLETS = _svg(P.droplets); export const ICON_SUN_DIM = _svg(P.sunDim); export const ICON_CAMERA = _svg(P.camera); export const ICON_WRENCH = _svg(P.wrench); diff --git a/server/src/wled_controller/static/js/core/state.ts b/server/src/wled_controller/static/js/core/state.ts index f39af09..f1d61e2 100644 --- a/server/src/wled_controller/static/js/core/state.ts +++ b/server/src/wled_controller/static/js/core/state.ts @@ -296,10 +296,12 @@ export const automationsCacheObj = new DataCache({ }); automationsCacheObj.subscribe(v => { _automationsCache = v; }); +export let _cachedColorStripSources: ColorStripSource[] = []; export const colorStripSourcesCache = new DataCache({ endpoint: '/color-strip-sources', extractData: json => json.sources || [], }); +colorStripSourcesCache.subscribe(v => { _cachedColorStripSources = v; }); export const csptCache = new DataCache({ endpoint: '/color-strip-processing-templates', diff --git a/server/src/wled_controller/static/js/core/tab-indicator.ts b/server/src/wled_controller/static/js/core/tab-indicator.ts index 7693915..0a0f970 100644 --- a/server/src/wled_controller/static/js/core/tab-indicator.ts +++ b/server/src/wled_controller/static/js/core/tab-indicator.ts @@ -1,6 +1,9 @@ /** * Tab indicator — large semi-transparent blurred icon on the right side * of the viewport, reflecting the currently active tab. + * + * Visible whenever any background effect is active (Noise Field, shader, + * CSS-based). Hidden only when no background effect is selected. */ const TAB_SVGS = { @@ -23,14 +26,21 @@ function _ensureEl() { return _el; } +/** Check if any background effect is currently active. */ +function _isBgActive(): boolean { + const html = document.documentElement; + return html.getAttribute('data-bg-anim') === 'on' + || html.hasAttribute('data-bg-effect'); +} + export function updateTabIndicator(tabName) { if (tabName === _currentTab) return; _currentTab = tabName; const svg = TAB_SVGS[tabName]; if (!svg) return; - // Respect the dynamic background toggle — hide when bg-anim is off - if (document.documentElement.getAttribute('data-bg-anim') !== 'on') { + // Hide when no background effect is active + if (!_isBgActive()) { const el = _ensureEl(); el.classList.remove('tab-indicator-visible'); return; @@ -48,11 +58,10 @@ export function updateTabIndicator(tabName) { export function initTabIndicator() { _ensureEl(); - // Listen for bg-anim toggle to show/hide the indicator + // Listen for bg-anim and bg-effect changes to show/hide the indicator new MutationObserver(() => { - const on = document.documentElement.getAttribute('data-bg-anim') === 'on'; const el = _ensureEl(); - if (!on) { + if (!_isBgActive()) { el.classList.remove('tab-indicator-visible'); } else if (_currentTab) { // Re-trigger show for the current tab @@ -60,7 +69,7 @@ export function initTabIndicator() { _currentTab = null; updateTabIndicator(prev); } - }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-bg-anim'] }); + }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-bg-anim', 'data-bg-effect'] }); // Set initial tab from current active button const active = document.querySelector('.tab-btn.active') as HTMLElement | null; diff --git a/server/src/wled_controller/static/js/core/ui.ts b/server/src/wled_controller/static/js/core/ui.ts index 9ad0841..216d52e 100644 --- a/server/src/wled_controller/static/js/core/ui.ts +++ b/server/src/wled_controller/static/js/core/ui.ts @@ -107,6 +107,8 @@ export function closeLightbox(event?: Event) { if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src); img.src = ''; img.style.display = ''; + const content = lightbox.querySelector('.lightbox-content') as HTMLElement | null; + if (content) content.style.width = ''; // Reset any custom width document.getElementById('lightbox-stats')!.style.display = 'none'; const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null; if (spinner) spinner.style.display = 'none'; diff --git a/server/src/wled_controller/static/js/features/advanced-calibration.ts b/server/src/wled_controller/static/js/features/advanced-calibration.ts index 1225fe5..e9e6e1b 100644 --- a/server/src/wled_controller/static/js/features/advanced-calibration.ts +++ b/server/src/wled_controller/static/js/features/advanced-calibration.ts @@ -29,6 +29,7 @@ interface MonitorRect { interface CalibrationState { cssId: string | null; + sourceType: string; lines: CalibrationLine[]; monitors: MonitorRect[]; pictureSources: PictureSource[]; @@ -94,6 +95,7 @@ const LINE_THICKNESS_PX = 6; let _state: CalibrationState = { cssId: null, + sourceType: 'picture_advanced', lines: [], monitors: [], pictureSources: [], @@ -122,6 +124,7 @@ class AdvancedCalibrationModal extends Modal { onForceClose(): void { if (_lineSourceEntitySelect) { _lineSourceEntitySelect.destroy(); _lineSourceEntitySelect = null; } _state.cssId = null; + _state.sourceType = 'picture_advanced'; _state.lines = []; _state.totalLedCount = 0; _state.selectedLine = -1; @@ -144,6 +147,7 @@ export async function showAdvancedCalibration(cssId: string): Promise { const psList = psResp.ok ? ((await psResp.json()).streams || []) : []; _state.cssId = cssId; + _state.sourceType = source.source_type || 'picture_advanced'; _state.pictureSources = psList; _state.totalLedCount = source.led_count || 0; @@ -219,7 +223,7 @@ export async function saveAdvancedCalibration(): Promise { try { const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`, { method: 'PUT', - body: JSON.stringify({ calibration }), + body: JSON.stringify({ source_type: _state.sourceType, calibration }), }); if (resp.ok) { diff --git a/server/src/wled_controller/static/js/features/appearance.ts b/server/src/wled_controller/static/js/features/appearance.ts index d77cf4c..274b1bd 100644 --- a/server/src/wled_controller/static/js/features/appearance.ts +++ b/server/src/wled_controller/static/js/features/appearance.ts @@ -391,7 +391,7 @@ export function applyBgEffect(id: string): void { /** Restore saved presets on page load. Called from init. */ export function initAppearance(): void { _activeStyleId = localStorage.getItem(LS_STYLE_PRESET) || 'default'; - _activeBgEffectId = localStorage.getItem(LS_BG_EFFECT) || 'none'; + _activeBgEffectId = localStorage.getItem(LS_BG_EFFECT) || 'noise'; // Apply style preset silently (without toast) const preset = STYLE_PRESETS.find(p => p.id === _activeStyleId); diff --git a/server/src/wled_controller/static/js/features/calibration.ts b/server/src/wled_controller/static/js/features/calibration.ts index 92f8ec2..b165651 100644 --- a/server/src/wled_controller/static/js/features/calibration.ts +++ b/server/src/wled_controller/static/js/features/calibration.ts @@ -241,9 +241,10 @@ export async function showCSSCalibration(cssId: any) { if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; } const calibration: Calibration = source.calibration || {} as Calibration; - // Set CSS mode — clear device-id, set css-id + // Set CSS mode — clear device-id, set css-id and source type (document.getElementById('calibration-device-id') as HTMLInputElement).value = ''; (document.getElementById('calibration-css-id') as HTMLInputElement).value = cssId; + (document.getElementById('calibration-css-source-type') as HTMLInputElement).value = source.source_type || 'picture'; // Populate device picker for edge test const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement; @@ -931,9 +932,10 @@ export async function saveCalibration() { try { let response; if (cssMode) { + const cssSourceType = (document.getElementById('calibration-css-source-type') as HTMLInputElement).value || 'picture'; response = await fetchWithAuth(`/color-strip-sources/${cssId}`, { method: 'PUT', - body: JSON.stringify({ calibration, led_count: declaredLedCount }), + body: JSON.stringify({ source_type: cssSourceType, calibration, led_count: declaredLedCount }), }); } else { response = await fetchWithAuth(`/devices/${deviceId}/calibration`, { diff --git a/server/src/wled_controller/static/js/features/color-strips-notification.ts b/server/src/wled_controller/static/js/features/color-strips-notification.ts index d0c8fdc..7e84c98 100644 --- a/server/src/wled_controller/static/js/features/color-strips-notification.ts +++ b/server/src/wled_controller/static/js/features/color-strips-notification.ts @@ -16,6 +16,7 @@ import { attachNotificationAppPicker, NotificationAppPalette } from '../core/pro import { _cachedAssets, _cachedValueSources, assetsCache } from '../core/state.ts'; import { getBaseOrigin } from './settings.ts'; import { BindableScalarWidget } from '../core/bindable-scalar.ts'; +import { BindableColorWidget } from '../core/bindable-color.ts'; const _icon = (d: any) => `${d}`; @@ -38,6 +39,33 @@ export function notificationGetRawAppOverrides() { let _notificationEffectIconSelect: any = null; let _notificationFilterModeIconSelect: any = null; let _notificationDurationWidget: BindableScalarWidget | null = null; +let _notificationDefaultColorWidget: BindableColorWidget | null = null; +let _notificationVolumeWidget: BindableScalarWidget | null = null; + +function _ensureNotificationVolumeWidget(): BindableScalarWidget { + if (!_notificationVolumeWidget) { + _notificationVolumeWidget = new BindableScalarWidget({ + container: document.getElementById('css-editor-notification-volume-container')!, + min: 0, max: 1, step: 0.05, default: 1.0, + idPrefix: 'css-editor-notification-volume', + format: (v) => `${Math.round(v * 100)}%`, + valueSources: () => _cachedValueSources, + }); + } + return _notificationVolumeWidget; +} + +function _ensureNotificationDefaultColorWidget(): BindableColorWidget { + if (!_notificationDefaultColorWidget) { + _notificationDefaultColorWidget = new BindableColorWidget({ + container: document.getElementById('css-editor-notification-default-color-container')!, + default: [255, 255, 255], + idPrefix: 'css-editor-notification-default-color', + valueSources: () => _cachedValueSources, + }); + } + return _notificationDefaultColorWidget; +} function _ensureNotificationDurationWidget(): BindableScalarWidget { if (!_notificationDurationWidget) { @@ -54,6 +82,8 @@ function _ensureNotificationDurationWidget(): BindableScalarWidget { export function destroyNotificationDurationWidget(): void { if (_notificationDurationWidget) { _notificationDurationWidget.destroy(); _notificationDurationWidget = null; } + if (_notificationDefaultColorWidget) { _notificationDefaultColorWidget.destroy(); _notificationDefaultColorWidget = null; } + if (_notificationVolumeWidget) { _notificationVolumeWidget.destroy(); _notificationVolumeWidget = null; } } export function getNotificationDurationValue(): number | { value: number; source_id: string } { @@ -64,6 +94,22 @@ export function getNotificationDurationSnapshot(): string { return _notificationDurationWidget ? JSON.stringify(_notificationDurationWidget.getValue()) : '1500'; } +export function getNotificationDefaultColorValue(): any { + return _notificationDefaultColorWidget ? _notificationDefaultColorWidget.getValue() : [255, 255, 255]; +} + +export function getNotificationVolumeValue(): any { + return _notificationVolumeWidget ? _notificationVolumeWidget.getValue() : 1.0; +} + +export function getNotificationVolumeSnapshot(): string { + return _notificationVolumeWidget ? JSON.stringify(_notificationVolumeWidget.getValue()) : '1.0'; +} + +export function getNotificationDefaultColorSnapshot(): string { + return _notificationDefaultColorWidget ? JSON.stringify(_notificationDefaultColorWidget.getValue()) : '[255,255,255]'; +} + export function ensureNotificationEffectIconSelect() { const sel = document.getElementById('css-editor-notification-effect') as HTMLSelectElement | null; if (!sel) return; @@ -374,7 +420,7 @@ export async function loadNotificationState(css: any) { (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash'; if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash'); _ensureNotificationDurationWidget().setValue(css.duration_ms ?? 1500); - (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = css.default_color || '#ffffff'; + _ensureNotificationDefaultColorWidget().setValue(css.default_color); (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = css.app_filter_mode || 'off'; if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(css.app_filter_mode || 'off'); (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = (css.app_filter_list || []).join('\n'); @@ -390,9 +436,7 @@ export async function loadNotificationState(css: any) { if (soundSel) soundSel.value = css.sound_asset_id || ''; ensureNotifSoundEntitySelect(); if (_notifSoundEntitySelect && css.sound_asset_id) _notifSoundEntitySelect.setValue(css.sound_asset_id); - const volPct = Math.round((css.sound_volume ?? 1.0) * 100); - (document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = volPct as any; - (document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = `${volPct}%`; + _ensureNotificationVolumeWidget().setValue(css.sound_volume ?? 1.0); // Unified per-app overrides (merge app_colors + app_sounds) _notificationAppOverrides = _mergeOverrides(css.app_colors || {}, css.app_sounds || {}); @@ -406,7 +450,7 @@ export async function resetNotificationState() { (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash'; if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash'); _ensureNotificationDurationWidget().setValue(1500); - (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = '#ffffff'; + _ensureNotificationDefaultColorWidget().setValue([255, 255, 255]); (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = 'off'; if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off'); (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = ''; @@ -418,8 +462,7 @@ export async function resetNotificationState() { _populateSoundOptions(soundSel); if (soundSel) soundSel.value = ''; ensureNotifSoundEntitySelect(); - (document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = 100 as any; - (document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = '100%'; + _ensureNotificationVolumeWidget().setValue(1.0); // Clear overrides _notificationAppOverrides = []; diff --git a/server/src/wled_controller/static/js/features/color-strips-test.ts b/server/src/wled_controller/static/js/features/color-strips-test.ts index 09d0c3e..20e1543 100644 --- a/server/src/wled_controller/static/js/features/color-strips-test.ts +++ b/server/src/wled_controller/static/js/features/color-strips-test.ts @@ -15,7 +15,7 @@ import { import { EntitySelect } from '../core/entity-palette.ts'; import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts'; import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts'; -import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue } from './color-strips-notification.ts'; +import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue, getNotificationVolumeValue } from './color-strips-notification.ts'; /* ── Preview config builder ───────────────────────────────────── */ @@ -55,7 +55,7 @@ function _collectPreviewConfig() { app_filter_list: filterList, app_colors: notificationGetAppColorsDict(), sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null, - sound_volume: parseInt((document.getElementById('css-editor-notification-volume') as HTMLInputElement).value) / 100, + sound_volume: getNotificationVolumeValue(), app_sounds: notificationGetAppSoundsDict(), }; } @@ -167,8 +167,11 @@ function _testKeyColorsSource(sourceId: string) { // Show lightbox with spinner const lightbox = document.getElementById('image-lightbox')!; const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null; + const content = lightbox.querySelector('.lightbox-content') as HTMLElement | null; + if (content) content.style.width = '90vw'; // Fill viewport for KC preview const img = document.getElementById('lightbox-image') as HTMLImageElement; img.src = ''; + img.style.display = 'none'; // Hide until first frame arrives if (spinner) spinner.style.display = ''; document.getElementById('lightbox-stats')!.style.display = 'none'; lightbox.classList.add('active'); diff --git a/server/src/wled_controller/static/js/features/color-strips.ts b/server/src/wled_controller/static/js/features/color-strips.ts index 860eaae..b9f0d4f 100644 --- a/server/src/wled_controller/static/js/features/color-strips.ts +++ b/server/src/wled_controller/static/js/features/color-strips.ts @@ -18,11 +18,12 @@ import { import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; import type { ColorStripSource } from '../types.ts'; -import { bindableValue } from '../types.ts'; +import { bindableValue, bindableColor } from '../types.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { IconSelect, showTypePicker } from '../core/icon-select.ts'; import { EntitySelect } from '../core/entity-palette.ts'; import { BindableScalarWidget } from '../core/bindable-scalar.ts'; +import { BindableColorWidget } from '../core/bindable-color.ts'; import { getBaseOrigin } from './settings.ts'; import { rgbArrayToHex, hexToRgbArray, @@ -42,6 +43,8 @@ import { testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory, loadNotificationState, resetNotificationState, showNotificationEndpoint, destroyNotificationDurationWidget, getNotificationDurationValue, getNotificationDurationSnapshot, + getNotificationDefaultColorValue, getNotificationDefaultColorSnapshot, + getNotificationVolumeValue, getNotificationVolumeSnapshot, } from './color-strips-notification.ts'; // Re-export for app.js window global bindings @@ -72,6 +75,12 @@ class CSSEditorModal extends Modal { if (_candlelightWindWidget) { _candlelightWindWidget.destroy(); _candlelightWindWidget = null; } if (_weatherSpeedWidget) { _weatherSpeedWidget.destroy(); _weatherSpeedWidget = null; } if (_weatherTempInfluenceWidget) { _weatherTempInfluenceWidget.destroy(); _weatherTempInfluenceWidget = null; } + if (_staticColorWidget) { _staticColorWidget.destroy(); _staticColorWidget = null; } + if (_effectColorWidget) { _effectColorWidget.destroy(); _effectColorWidget = null; } + if (_audioColorWidget) { _audioColorWidget.destroy(); _audioColorWidget = null; } + if (_audioColorPeakWidget) { _audioColorPeakWidget.destroy(); _audioColorPeakWidget = null; } + if (_apiInputFallbackColorWidget) { _apiInputFallbackColorWidget.destroy(); _apiInputFallbackColorWidget = null; } + if (_candlelightColorWidget) { _candlelightColorWidget.destroy(); _candlelightColorWidget = null; } destroyNotificationDurationWidget(); if (_kcPictureSourceEntitySelect) { _kcPictureSourceEntitySelect.destroy(); _kcPictureSourceEntitySelect = null; } if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; } @@ -88,14 +97,14 @@ class CSSEditorModal extends Modal { picture_source: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value, interpolation: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value, smoothing: _smoothingWidget ? JSON.stringify(_smoothingWidget.getValue()) : '0.3', - color: (document.getElementById('css-editor-color') as HTMLInputElement).value, + color: _staticColorWidget ? JSON.stringify(_staticColorWidget.getValue()) : '[]', led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value, gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]', animation_type: (document.getElementById('css-editor-animation-type') as HTMLInputElement).value, cycle_colors: JSON.stringify(_colorCycleColors), effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, effect_palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, - effect_color: (document.getElementById('css-editor-effect-color') as HTMLInputElement).value, + effect_color: _effectColorWidget ? JSON.stringify(_effectColorWidget.getValue()) : '[]', effect_intensity: _effectIntensityWidget ? JSON.stringify(_effectIntensityWidget.getValue()) : '1.0', effect_scale: _effectScaleWidget ? JSON.stringify(_effectScaleWidget.getValue()) : '1.0', effect_mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked, @@ -106,16 +115,16 @@ class CSSEditorModal extends Modal { audio_sensitivity: _audioSensitivityWidget ? JSON.stringify(_audioSensitivityWidget.getValue()) : '1.0', audio_smoothing: _audioSmoothingWidget ? JSON.stringify(_audioSmoothingWidget.getValue()) : '0.3', audio_palette: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value, - audio_color: (document.getElementById('css-editor-audio-color') as HTMLInputElement).value, - audio_color_peak: (document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value, + audio_color: _audioColorWidget ? JSON.stringify(_audioColorWidget.getValue()) : '[]', + audio_color_peak: _audioColorPeakWidget ? JSON.stringify(_audioColorPeakWidget.getValue()) : '[]', audio_mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked, - api_input_fallback_color: (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value, + api_input_fallback_color: _apiInputFallbackColorWidget ? JSON.stringify(_apiInputFallbackColorWidget.getValue()) : '[]', api_input_timeout: _apiInputTimeoutWidget ? JSON.stringify(_apiInputTimeoutWidget.getValue()) : '5.0', api_input_interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLInputElement).value, notification_os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked, notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value, notification_duration: getNotificationDurationSnapshot(), - notification_default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value, + notification_default_color: getNotificationDefaultColorSnapshot(), notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value, notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value, notification_app_overrides: JSON.stringify(notificationGetRawAppOverrides()), @@ -123,7 +132,7 @@ class CSSEditorModal extends Modal { daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value, daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, daylight_latitude: (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value, - candlelight_color: (document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value, + candlelight_color: _candlelightColorWidget ? JSON.stringify(_candlelightColorWidget.getValue()) : '[]', candlelight_intensity: _candlelightIntensityWidget ? JSON.stringify(_candlelightIntensityWidget.getValue()) : '1.0', candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value, candlelight_speed: _candlelightSpeedWidget ? JSON.stringify(_candlelightSpeedWidget.getValue()) : '1.0', @@ -152,6 +161,14 @@ let _candlelightWindWidget: BindableScalarWidget | null = null; let _weatherSpeedWidget: BindableScalarWidget | null = null; let _weatherTempInfluenceWidget: BindableScalarWidget | null = null; +// ── BindableColorWidget instances for CSS editor ── +let _staticColorWidget: BindableColorWidget | null = null; +let _effectColorWidget: BindableColorWidget | null = null; +let _audioColorWidget: BindableColorWidget | null = null; +let _audioColorPeakWidget: BindableColorWidget | null = null; +let _apiInputFallbackColorWidget: BindableColorWidget | null = null; +let _candlelightColorWidget: BindableColorWidget | null = null; + // ── EntitySelect instances for CSS editor ── let _cssPictureSourceEntitySelect: any = null; let _cssAudioSourceEntitySelect: any = null; @@ -204,6 +221,7 @@ async function configureKCRegions(sourceId: string): Promise { if (!showPatternTemplateEditor) return; showPatternTemplateEditor(null, null, { rects: rects.map((r: any) => ({ ...r })), + pictureSourceId: source.picture_source_id || '', onSave: async (newRects: any[]) => { // Save rectangles back to the CSS source try { @@ -286,9 +304,9 @@ const CSS_ALL_SECTION_IDS = [...new Set(Object.values(CSS_SECTION_MAP))]; const CSS_TYPE_SETUP: Record void> = { processed: () => _populateProcessedSelectors(), - effect: () => { _ensureEffectTypeIconSelect(); _ensureEffectPaletteIconSelect(); onEffectTypeChange(); }, - audio: () => { _ensureAudioVizIconSelect(); _ensureAudioPaletteIconSelect(); onAudioVizChange(); _loadAudioSources(); }, - gradient: () => { _ensureGradientPresetIconSelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); }, + effect: () => { _ensureEffectTypeIconSelect(); _ensureEffectPaletteEntitySelect(); onEffectTypeChange(); }, + audio: () => { _ensureAudioVizIconSelect(); _ensureAudioPaletteEntitySelect(); onAudioVizChange(); _loadAudioSources(); }, + gradient: () => { _ensureGradientPresetEntitySelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); }, notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); }, candlelight: () => _ensureCandleTypeIconSelect(), weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); }, @@ -491,10 +509,10 @@ function _syncDaylightSpeedVisibility() { let _animationTypeIconSelect: any = null; let _interpolationIconSelect: any = null; let _effectTypeIconSelect: any = null; -let _effectPaletteIconSelect: any = null; -let _audioPaletteIconSelect: any = null; +let _effectPaletteEntitySelect: EntitySelect | null = null; +let _audioPaletteEntitySelect: EntitySelect | null = null; let _audioVizIconSelect: any = null; -let _gradientPresetIconSelect: any = null; +let _gradientPresetEntitySelect: EntitySelect | null = null; let _gradientEasingIconSelect: any = null; let _candleTypeIconSelect: any = null; let _apiInputInterpolationIconSelect: any = null; @@ -681,6 +699,78 @@ function _ensureWeatherTempInfluenceWidget(): BindableScalarWidget { return _weatherTempInfluenceWidget; } +function _ensureStaticColorWidget(): BindableColorWidget { + if (!_staticColorWidget) { + _staticColorWidget = new BindableColorWidget({ + container: document.getElementById('css-editor-color-container')!, + default: [255, 255, 255], + valueSources: () => _cachedValueSources, + idPrefix: 'css-editor-color', + }); + } + return _staticColorWidget; +} + +function _ensureEffectColorWidget(): BindableColorWidget { + if (!_effectColorWidget) { + _effectColorWidget = new BindableColorWidget({ + container: document.getElementById('css-editor-effect-color-container')!, + default: [255, 80, 0], + valueSources: () => _cachedValueSources, + idPrefix: 'css-editor-effect-color', + }); + } + return _effectColorWidget; +} + +function _ensureAudioColorWidget(): BindableColorWidget { + if (!_audioColorWidget) { + _audioColorWidget = new BindableColorWidget({ + container: document.getElementById('css-editor-audio-color-container')!, + default: [0, 255, 0], + valueSources: () => _cachedValueSources, + idPrefix: 'css-editor-audio-color', + }); + } + return _audioColorWidget; +} + +function _ensureAudioColorPeakWidget(): BindableColorWidget { + if (!_audioColorPeakWidget) { + _audioColorPeakWidget = new BindableColorWidget({ + container: document.getElementById('css-editor-audio-color-peak-container')!, + default: [255, 0, 0], + valueSources: () => _cachedValueSources, + idPrefix: 'css-editor-audio-color-peak', + }); + } + return _audioColorPeakWidget; +} + +function _ensureApiInputFallbackColorWidget(): BindableColorWidget { + if (!_apiInputFallbackColorWidget) { + _apiInputFallbackColorWidget = new BindableColorWidget({ + container: document.getElementById('css-editor-api-input-fallback-color-container')!, + default: [0, 0, 0], + valueSources: () => _cachedValueSources, + idPrefix: 'css-editor-api-input-fallback-color', + }); + } + return _apiInputFallbackColorWidget; +} + +function _ensureCandlelightColorWidget(): BindableColorWidget { + if (!_candlelightColorWidget) { + _candlelightColorWidget = new BindableColorWidget({ + container: document.getElementById('css-editor-candlelight-color-container')!, + default: [255, 147, 41], + valueSources: () => _cachedValueSources, + idPrefix: 'css-editor-candlelight-color', + }); + } + return _candlelightColorWidget; +} + function _ensureApiInputInterpolationIconSelect() { const sel = document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement | null; if (!sel) return; @@ -714,13 +804,17 @@ function _ensureEffectTypeIconSelect() { _effectTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 }); } -function _ensureEffectPaletteIconSelect() { +function _ensureEffectPaletteEntitySelect() { const sel = document.getElementById('css-editor-effect-palette') as HTMLSelectElement | null; if (!sel) return; const items = _buildGradientEntityItems(); _syncSelectOptions(sel, items); - if (_effectPaletteIconSelect) { _effectPaletteIconSelect.updateItems(items); return; } - _effectPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2, searchable: true, searchPlaceholder: t('palette.search') }); + if (_effectPaletteEntitySelect) { _effectPaletteEntitySelect.refresh(); return; } + _effectPaletteEntitySelect = new EntitySelect({ + target: sel, + getItems: _buildGradientEntityItems, + placeholder: t('palette.search'), + }); } function _ensureGradientEasingIconSelect() { @@ -749,13 +843,17 @@ function _ensureCandleTypeIconSelect() { _candleTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 }); } -function _ensureAudioPaletteIconSelect() { +function _ensureAudioPaletteEntitySelect() { const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null; if (!sel) return; const items = _buildGradientEntityItems(); _syncSelectOptions(sel, items); - if (_audioPaletteIconSelect) { _audioPaletteIconSelect.updateItems(items); return; } - _audioPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2, searchable: true, searchPlaceholder: t('palette.search') }); + if (_audioPaletteEntitySelect) { _audioPaletteEntitySelect.refresh(); return; } + _audioPaletteEntitySelect = new EntitySelect({ + target: sel, + getItems: _buildGradientEntityItems, + placeholder: t('palette.search'), + }); } function _ensureAudioVizIconSelect() { @@ -788,21 +886,29 @@ function _syncSelectOptions(sel: HTMLSelectElement, items: Array<{ value: string } } -function _ensureGradientPresetIconSelect() { +function _ensureGradientPresetEntitySelect() { const sel = document.getElementById('css-editor-gradient-preset') as HTMLSelectElement | null; if (!sel) return; const items = _buildGradientEntityItems(); _syncSelectOptions(sel, items); - if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; } - _gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3, searchable: true, searchPlaceholder: t('palette.search') }); + if (_gradientPresetEntitySelect) { _gradientPresetEntitySelect.refresh(); return; } + _gradientPresetEntitySelect = new EntitySelect({ + target: sel, + getItems: _buildGradientEntityItems, + placeholder: t('palette.search'), + }); } /** Rebuild the gradient picker after entity changes. */ export function refreshGradientPresetPicker() { - const items = _buildGradientEntityItems(); - if (_gradientPresetIconSelect) _gradientPresetIconSelect.updateItems(items); - if (_effectPaletteIconSelect) _effectPaletteIconSelect.updateItems(items); - if (_audioPaletteIconSelect) _audioPaletteIconSelect.updateItems(items); + // Re-sync select options before refreshing entity selects + for (const selId of ['css-editor-gradient-preset', 'css-editor-effect-palette', 'css-editor-audio-palette']) { + const sel = document.getElementById(selId) as HTMLSelectElement | null; + if (sel) _syncSelectOptions(sel, _buildGradientEntityItems()); + } + if (_gradientPresetEntitySelect) _gradientPresetEntitySelect.refresh(); + if (_effectPaletteEntitySelect) _effectPaletteEntitySelect.refresh(); + if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.refresh(); } /** Render the user-created gradient list below the save button. */ @@ -1198,9 +1304,9 @@ function _loadAudioState(css: any) { const audioGradientId = css.gradient_id || 'gr_builtin_rainbow'; (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId; - if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue(audioGradientId); - (document.getElementById('css-editor-audio-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [0, 255, 0]); - (document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value = rgbArrayToHex(css.color_peak || [255, 0, 0]); + if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.setValue(audioGradientId); + _ensureAudioColorWidget().setValue(css.color); + _ensureAudioColorPeakWidget().setValue(css.color_peak); (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = css.mirror || false; // Set audio source selector @@ -1216,9 +1322,9 @@ function _resetAudioState() { _ensureAudioSensitivityWidget().setValue(1.0); _ensureAudioSmoothingWidget().setValue(0.3); (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow'; - if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue('gr_builtin_rainbow'); - (document.getElementById('css-editor-audio-color') as HTMLInputElement).value = '#00ff00'; - (document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value = '#ff0000'; + if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.setValue('gr_builtin_rainbow'); + _ensureAudioColorWidget().setValue([0, 255, 0]); + _ensureAudioColorPeakWidget().setValue([255, 0, 0]); (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = false; } @@ -1238,7 +1344,7 @@ const NON_PICTURE_TYPES = new Set([ const CSS_CARD_RENDERERS: Record = { static: (source, { clockBadge, animBadge }) => { - const hexColor = rgbArrayToHex(source.color!); + const hexColor = rgbArrayToHex(bindableColor(source.color, [255,255,255])); return ` ${hexColor.toUpperCase()} @@ -1320,7 +1426,7 @@ const CSS_CARD_RENDERERS: Record = { `; }, api_input: (source) => { - const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]); + const fbColor = rgbArrayToHex(bindableColor(source.fallback_color, [0, 0, 0])); const timeoutVal = bindableValue(source.timeout, 5.0).toFixed(1); const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear'; return ` @@ -1334,13 +1440,14 @@ const CSS_CARD_RENDERERS: Record = { notification: (source) => { const effectLabel = t('color_strip.notification.effect.' + (source.notification_effect || 'flash')) || source.notification_effect || 'flash'; const durationVal = source.duration_ms || 1500; - const defColor = source.default_color || '#FFFFFF'; + const defColorRgb = bindableColor(source.default_color as any, [255, 255, 255]); + const defColorHex = rgbArrayToHex(defColorRgb); const appCount = source.app_colors ? Object.keys(source.app_colors).length : 0; return ` ${ICON_BELL} ${escapeHtml(effectLabel)} ${ICON_TIMER} ${durationVal}ms - ${defColor.toUpperCase()} + ${defColorHex.toUpperCase()} ${appCount > 0 ? `${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}` : ''} `; @@ -1354,7 +1461,7 @@ const CSS_CARD_RENDERERS: Record = { `; }, candlelight: (source, { clockBadge }) => { - const hexColor = rgbArrayToHex(source.color || [255, 147, 41]); + const hexColor = rgbArrayToHex(bindableColor(source.color, [255, 147, 41])); const numCandles = source.num_candles ?? 3; return ` @@ -1539,17 +1646,17 @@ function _autoGenerateCSSName() { const _typeHandlers: Record any; reset: (...args: any[]) => any; getPayload: (name: any) => any }> = { static: { load(css) { - (document.getElementById('css-editor-color') as HTMLInputElement).value = rgbArrayToHex(css.color); + _ensureStaticColorWidget().setValue(css.color); _loadAnimationState(css.animation); }, reset() { - (document.getElementById('css-editor-color') as HTMLInputElement).value = '#ffffff'; + _ensureStaticColorWidget().setValue([255, 255, 255]); _loadAnimationState(null); }, getPayload(name) { return { name, - color: hexToRgbArray((document.getElementById('css-editor-color') as HTMLInputElement).value), + color: _ensureStaticColorWidget().getValue(), animation: _getAnimationPayload(), }; }, @@ -1573,20 +1680,20 @@ const _typeHandlers: Record any; reset: (... gradient: { load(css) { const gradientId = css.gradient_id || ''; - _ensureGradientPresetIconSelect(); + _ensureGradientPresetEntitySelect(); (document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = gradientId; - if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue(gradientId); + if (_gradientPresetEntitySelect) _gradientPresetEntitySelect.setValue(gradientId); _loadAnimationState(css.animation); (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = css.easing || 'linear'; if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue(css.easing || 'linear'); }, reset() { - _ensureGradientPresetIconSelect(); + _ensureGradientPresetEntitySelect(); // Default to first gradient const gradients = _getGradients(); const defaultId = gradients.length > 0 ? gradients[0].id : ''; (document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = defaultId; - if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue(defaultId); + if (_gradientPresetEntitySelect) _gradientPresetEntitySelect.setValue(defaultId); _loadAnimationState(null); (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = 'linear'; if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue('linear'); @@ -1612,8 +1719,8 @@ const _typeHandlers: Record any; reset: (... onEffectTypeChange(); const gradientId = css.gradient_id || 'gr_builtin_fire'; (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = gradientId; - if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(gradientId); - (document.getElementById('css-editor-effect-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 80, 0]); + if (_effectPaletteEntitySelect) _effectPaletteEntitySelect.setValue(gradientId); + _ensureEffectColorWidget().setValue(css.color); _ensureEffectIntensityWidget().setValue(css.intensity ?? 1.0); _ensureEffectScaleWidget().setValue(css.scale ?? 1.0); (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false; @@ -1622,8 +1729,8 @@ const _typeHandlers: Record any; reset: (... reset() { (document.getElementById('css-editor-effect-type') as HTMLInputElement).value = 'fire'; (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = 'gr_builtin_fire'; - if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue('gr_builtin_fire'); - (document.getElementById('css-editor-effect-color') as HTMLInputElement).value = '#ff5000'; + if (_effectPaletteEntitySelect) _effectPaletteEntitySelect.setValue('gr_builtin_fire'); + _ensureEffectColorWidget().setValue([255, 80, 0]); _ensureEffectIntensityWidget().setValue(1.0); _ensureEffectScaleWidget().setValue(1.0); (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false; @@ -1639,8 +1746,7 @@ const _typeHandlers: Record any; reset: (... }; // Meteor/comet/bouncing_ball use a color picker if (['meteor', 'comet', 'bouncing_ball'].includes(payload.effect_type)) { - const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; - payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; + payload.color = _ensureEffectColorWidget().getValue(); } return payload; }, @@ -1661,8 +1767,8 @@ const _typeHandlers: Record any; reset: (... sensitivity: _ensureAudioSensitivityWidget().getValue(), smoothing: _ensureAudioSmoothingWidget().getValue(), gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value, - color: hexToRgbArray((document.getElementById('css-editor-audio-color') as HTMLInputElement).value), - color_peak: hexToRgbArray((document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value), + color: _ensureAudioColorWidget().getValue(), + color_peak: _ensureAudioColorPeakWidget().getValue(), mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked, }; }, @@ -1707,25 +1813,23 @@ const _typeHandlers: Record any; reset: (... }, api_input: { load(css) { - (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value = - rgbArrayToHex(css.fallback_color || [0, 0, 0]); + _ensureApiInputFallbackColorWidget().setValue(css.fallback_color); _ensureApiInputTimeoutWidget().setValue(css.timeout ?? 5.0); (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = css.interpolation || 'linear'; _ensureApiInputInterpolationIconSelect(); _showApiInputEndpoints(css.id); }, reset() { - (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value = '#000000'; + _ensureApiInputFallbackColorWidget().setValue([0, 0, 0]); _ensureApiInputTimeoutWidget().setValue(5.0); (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = 'linear'; _ensureApiInputInterpolationIconSelect(); _showApiInputEndpoints(null); }, getPayload(name) { - const fbHex = (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value; return { name, - fallback_color: hexToRgbArray(fbHex), + fallback_color: _ensureApiInputFallbackColorWidget().getValue(), timeout: _ensureApiInputTimeoutWidget().getValue(), interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value, }; @@ -1746,12 +1850,12 @@ const _typeHandlers: Record any; reset: (... os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked, notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value, duration_ms: getNotificationDurationValue(), - default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value, + default_color: getNotificationDefaultColorValue(), app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value, app_filter_list: filterList, app_colors: notificationGetAppColorsDict(), sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null, - sound_volume: parseInt((document.getElementById('css-editor-notification-volume') as HTMLInputElement).value) / 100, + sound_volume: getNotificationVolumeValue(), app_sounds: notificationGetAppSoundsDict(), }; }, @@ -1788,7 +1892,7 @@ const _typeHandlers: Record any; reset: (... }, candlelight: { load(css) { - (document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 147, 41]); + _ensureCandlelightColorWidget().setValue(css.color); _ensureCandlelightIntensityWidget().setValue(css.intensity ?? 1.0); (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = css.num_candles ?? 3; _ensureCandlelightSpeedWidget().setValue(css.speed ?? 1.0); @@ -1797,7 +1901,7 @@ const _typeHandlers: Record any; reset: (... if (_candleTypeIconSelect) _candleTypeIconSelect.setValue(css.candle_type || 'default'); }, reset() { - (document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = '#ff9329'; + _ensureCandlelightColorWidget().setValue([255, 147, 41]); _ensureCandlelightIntensityWidget().setValue(1.0); (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = 3 as any; _ensureCandlelightSpeedWidget().setValue(1.0); @@ -1808,7 +1912,7 @@ const _typeHandlers: Record any; reset: (... getPayload(name) { return { name, - color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value), + color: _ensureCandlelightColorWidget().getValue(), intensity: _ensureCandlelightIntensityWidget().getValue(), num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3, speed: _ensureCandlelightSpeedWidget().getValue(), @@ -2172,7 +2276,7 @@ export async function saveCSSEditor() { const payload = handler.getPayload(name); if (payload === null) return; // validation error already shown - if (!cssId) payload.source_type = knownType ? sourceType : 'picture'; + payload.source_type = knownType ? sourceType : 'picture'; // Attach clock_id for animated types const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather']; diff --git a/server/src/wled_controller/static/js/features/dashboard.ts b/server/src/wled_controller/static/js/features/dashboard.ts index 633e1f0..b8304f0 100644 --- a/server/src/wled_controller/static/js/features/dashboard.ts +++ b/server/src/wled_controller/static/js/features/dashboard.ts @@ -354,10 +354,12 @@ function _sectionHeader(sectionKey: string, label: string, count: number | strin const collapsed = _getCollapsedSections(); const isCollapsed = !!collapsed[sectionKey]; const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"'; - return `
- - ${label} - ${count} + return `
+ + + ${label} + ${count} + ${extraHtml}
`; } diff --git a/server/src/wled_controller/static/js/features/graph-editor.ts b/server/src/wled_controller/static/js/features/graph-editor.ts index e7b7261..6caa04f 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.ts +++ b/server/src/wled_controller/static/js/features/graph-editor.ts @@ -682,7 +682,7 @@ async function _fetchAllEntities(): Promise> { devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(), streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(), valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(), - outputTargetsCache.fetch(), patternTemplatesCache.fetch(), scenePresetsCache.fetch(), + outputTargetsCache.fetch(), patternTemplatesCache.fetch().catch(() => []), scenePresetsCache.fetch(), automationsCacheObj.fetch(), csptCache.fetch(), fetchWithAuth('/output-targets/batch/states').catch(() => null), ]); diff --git a/server/src/wled_controller/static/js/features/ha-light-targets.ts b/server/src/wled_controller/static/js/features/ha-light-targets.ts index 2a43a88..3c0eab0 100644 --- a/server/src/wled_controller/static/js/features/ha-light-targets.ts +++ b/server/src/wled_controller/static/js/features/ha-light-targets.ts @@ -7,7 +7,7 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm, formatUptime } from '../core/ui.ts'; -import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, getColorStripIcon, getValueSourceIcon, getHAEntityIcon } from '../core/icons.ts'; +import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, ICON_SUN, getColorStripIcon, getValueSourceIcon, getHAEntityIcon } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { EntitySelect } from '../core/entity-palette.ts'; import { wrapCard } from '../core/card-colors.ts'; @@ -23,7 +23,7 @@ const _icon = (d: string) => `${d}`; let _haLightTagsInput: TagInput | null = null; let _haSourceEntitySelect: EntitySelect | null = null; let _cssSourceEntitySelect: EntitySelect | null = null; -let _brightnessVsEntitySelect: EntitySelect | null = null; +let _brightnessWidget: BindableScalarWidget | null = null; let _mappingEntitySelects: EntitySelect[] = []; let _editorCssSources: any[] = []; let _cachedHAEntities: any[] = []; // fetched from selected HA source @@ -39,7 +39,7 @@ class HALightEditorModal extends Modal { if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; } if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; } if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; } - if (_brightnessVsEntitySelect) { _brightnessVsEntitySelect.destroy(); _brightnessVsEntitySelect = null; } + if (_brightnessWidget) { _brightnessWidget.destroy(); _brightnessWidget = null; } if (_updateRateWidget) { _updateRateWidget.destroy(); _updateRateWidget = null; } if (_transitionWidget) { _transitionWidget.destroy(); _transitionWidget = null; } if (_colorToleranceWidget) { _colorToleranceWidget.destroy(); _colorToleranceWidget = null; } @@ -52,6 +52,7 @@ class HALightEditorModal extends Modal { name: (document.getElementById('ha-light-editor-name') as HTMLInputElement).value, ha_source: (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value, css_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value, + brightness: _brightnessWidget ? JSON.stringify(_brightnessWidget.getValue()) : '1.0', update_rate: _updateRateWidget ? JSON.stringify(_updateRateWidget.getValue()) : '2.0', transition: _transitionWidget ? JSON.stringify(_transitionWidget.getValue()) : '0.5', color_tolerance: _colorToleranceWidget ? JSON.stringify(_colorToleranceWidget.getValue()) : '5', @@ -215,6 +216,19 @@ export function removeHALightMapping(btn: HTMLElement): void { // ── Bindable scalar widgets ── +function _ensureBrightnessWidget(): BindableScalarWidget { + if (!_brightnessWidget) { + _brightnessWidget = new BindableScalarWidget({ + container: document.getElementById('ha-light-editor-brightness-container')!, + min: 0, max: 1, step: 0.05, default: 1.0, + idPrefix: 'ha-light-editor-brightness', + valueSources: () => _cachedValueSources, + format: (v) => v.toFixed(2), + }); + } + return _brightnessWidget; +} + function _ensureUpdateRateWidget(): BindableScalarWidget { if (!_updateRateWidget) { _updateRateWidget = new BindableScalarWidget({ @@ -322,6 +336,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat (document.getElementById('ha-light-editor-name') as HTMLInputElement).value = editData.name || ''; haSelect.value = editData.ha_source_id || ''; cssSelect.value = editData.color_strip_source_id || ''; + _ensureBrightnessWidget().setValue(editData.brightness ?? 1.0); _ensureUpdateRateWidget().setValue(editData.update_rate ?? 2.0); _ensureTransitionWidget().setValue(editData.transition ?? 0.5); _ensureColorToleranceWidget().setValue(editData.color_tolerance ?? 5); @@ -336,6 +351,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat mappings.forEach((m: any) => addHALightMapping(m)); } else { (document.getElementById('ha-light-editor-name') as HTMLInputElement).value = ''; + _ensureBrightnessWidget().setValue(1.0); _ensureUpdateRateWidget().setValue(2.0); _ensureTransitionWidget().setValue(0.5); _ensureColorToleranceWidget().setValue(5); @@ -375,23 +391,6 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat placeholder: t('palette.search'), }); - // Brightness value source - const bvsSelect = document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement; - bvsSelect.innerHTML = `` + - _cachedValueSources.map((vs: any) => - `` - ).join(''); - if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy(); - _brightnessVsEntitySelect = new EntitySelect({ - target: bvsSelect, - getItems: () => _cachedValueSources.map((vs: any) => ({ - value: vs.id, label: vs.name, icon: getValueSourceIcon(vs.source_type), desc: vs.source_type, - })), - placeholder: t('palette.search'), - allowNone: true, - noneLabel: t('targets.brightness_vs.none'), - }); - // Tags if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; } _haLightTagsInput = new TagInput(document.getElementById('ha-light-tags-container'), { placeholder: t('tags.placeholder') }); @@ -430,13 +429,13 @@ export async function saveHALightEditor(): Promise { // Collect mappings const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.entity_id); - const brightnessVsId = (document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement).value; + const brightness = _brightnessWidget ? _brightnessWidget.getValue() : 1.0; const payload: any = { name, ha_source_id: haSourceId, color_strip_source_id: cssSourceId, - brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0, + brightness, ha_light_mappings: mappings, update_rate: updateRate, transition, @@ -446,6 +445,8 @@ export async function saveHALightEditor(): Promise { tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [], }; + payload.target_type = 'ha_light'; + try { let response; if (targetId) { @@ -454,7 +455,6 @@ export async function saveHALightEditor(): Promise { body: JSON.stringify(payload), }); } else { - payload.target_type = 'ha_light'; response = await fetchWithAuth('/output-targets', { method: 'POST', body: JSON.stringify(payload), @@ -537,7 +537,7 @@ export function createHALightTargetCard(target: any, haSourceMap: Record${cssSource ? getColorStripIcon(cssSource.source_type) : _icon(P.palette)} ${cssName}` : ''} ${_icon(P.listChecks)} ${mappingCount} light${mappingCount !== 1 ? 's' : ''} ${_icon(P.clock)} ${target.update_rate ?? 2.0} Hz - ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''} + ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : (bindableValue(target.brightness, 1.0) < 1.0 ? `${ICON_SUN} ${Math.round(bindableValue(target.brightness, 1.0) * 100)}%` : '')}
${renderTagChips(target.tags || [])} ${target.description ? `
${escapeHtml(target.description)}
` : ''} diff --git a/server/src/wled_controller/static/js/features/pattern-templates.ts b/server/src/wled_controller/static/js/features/pattern-templates.ts index 8ee9cf4..6180e2d 100644 --- a/server/src/wled_controller/static/js/features/pattern-templates.ts +++ b/server/src/wled_controller/static/js/features/pattern-templates.ts @@ -101,7 +101,7 @@ export function createPatternTemplateCard(pt: PatternTemplate) { }); } -export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null, opts?: { rects?: any[]; onSave?: (rects: any[]) => void }): Promise { +export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null, opts?: { rects?: any[]; pictureSourceId?: string; onSave?: (rects: any[]) => void }): Promise { _inlineCallback = opts?.onSave || null; try { // Load sources for background capture @@ -199,6 +199,15 @@ export async function showPatternTemplateEditor(templateId: string | null = null (document.getElementById('pattern-template-error') as HTMLElement).style.display = 'none'; setTimeout(() => desktopFocus(document.getElementById('pattern-template-name')), 100); + + // Auto-capture background from picture source (if provided) + if (opts?.pictureSourceId) { + // Pre-select the source in dropdown + if (bgSelect) bgSelect.value = opts.pictureSourceId; + if (_patternBgEntitySelect) _patternBgEntitySelect.refresh(); + // Capture after a short delay to let canvas resize + setTimeout(() => capturePatternBackground(), 200); + } } catch (error) { console.error('Failed to open pattern template editor:', error); showToast(t('pattern.error.editor_open_failed'), 'error'); diff --git a/server/src/wled_controller/static/js/features/tabs.ts b/server/src/wled_controller/static/js/features/tabs.ts index 887017d..378d589 100644 --- a/server/src/wled_controller/static/js/features/tabs.ts +++ b/server/src/wled_controller/static/js/features/tabs.ts @@ -38,7 +38,7 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }: localStorage.setItem('activeTab', name); // Update background tab indicator - if (typeof window._updateTabIndicator === 'function') window._updateTabIndicator(); + if (typeof window._updateTabIndicator === 'function') window._updateTabIndicator(name); // Restore scroll position for this tab requestAnimationFrame(() => window.scrollTo(0, _tabScrollPositions[name] || 0)); diff --git a/server/src/wled_controller/static/js/features/targets.ts b/server/src/wled_controller/static/js/features/targets.ts index 8e827cb..097807c 100644 --- a/server/src/wled_controller/static/js/features/targets.ts +++ b/server/src/wled_controller/static/js/features/targets.ts @@ -23,7 +23,7 @@ import { getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon, ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW, - ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP, + ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP, ICON_WARNING, ICON_PALETTE, ICON_WRENCH, ICON_TEMPLATE, ICON_TRASH, } from '../core/icons.ts'; import { EntitySelect } from '../core/entity-palette.ts'; @@ -35,7 +35,7 @@ import { createFpsSparkline } from '../core/chart-utils.ts'; import { CardSection } from '../core/card-sections.ts'; import { TreeNav } from '../core/tree-nav.ts'; import { updateSubTabHash, updateTabBadge } from './tabs.ts'; -import type { OutputTarget } from '../types.ts'; +import type { OutputTarget, LedOutputTarget } from '../types.ts'; import { bindableSourceId, bindableValue } from '../types.ts'; import { BindableScalarWidget } from '../core/bindable-scalar.ts'; @@ -144,6 +144,7 @@ function _updateTargetFpsChart(targetId: any, fpsTarget: any) { // --- Editor state --- let _editorCssSources: any[] = []; // populated when editor opens let _targetTagsInput: TagInput | null = null; +let _brightnessWidget: BindableScalarWidget | null = null; let _fpsWidget: BindableScalarWidget | null = null; let _thresholdWidget: BindableScalarWidget | null = null; @@ -158,7 +159,7 @@ class TargetEditorModal extends Modal { device: (document.getElementById('target-editor-device') as HTMLSelectElement).value, protocol: (document.getElementById('target-editor-protocol') as HTMLSelectElement).value, css_source: (document.getElementById('target-editor-css-source') as HTMLSelectElement).value, - brightness_vs: (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value, + brightness: _brightnessWidget ? JSON.stringify(_brightnessWidget.getValue()) : '1.0', brightness_threshold: _thresholdWidget ? JSON.stringify(_thresholdWidget.getValue()) : '0', fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30', keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value, @@ -258,7 +259,6 @@ function _updateBrightnessThresholdVisibility() { // ── EntitySelect instances for target editor ── let _deviceEntitySelect: EntitySelect | null = null; let _cssEntitySelect: EntitySelect | null = null; -let _brightnessVsEntitySelect: EntitySelect | null = null; let _protocolIconSelect: IconSelect | null = null; function _populateCssDropdown(selectedId = '') { @@ -268,15 +268,6 @@ function _populateCssDropdown(selectedId = '') { ).join(''); } -function _populateBrightnessVsDropdown(selectedId = '') { - const select = document.getElementById('target-editor-brightness-vs') as HTMLSelectElement; - let html = ``; - _cachedValueSources.forEach(vs => { - html += ``; - }); - select.innerHTML = html; -} - function _ensureTargetEntitySelects() { // Device if (_deviceEntitySelect) _deviceEntitySelect.destroy(); @@ -304,20 +295,6 @@ function _ensureTargetEntitySelects() { placeholder: t('palette.search'), }); - // Brightness value source - if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy(); - _brightnessVsEntitySelect = new EntitySelect({ - target: document.getElementById('target-editor-brightness-vs') as HTMLSelectElement, - getItems: () => _cachedValueSources.map(vs => ({ - value: vs.id, - label: vs.name, - icon: getValueSourceIcon(vs.source_type), - desc: vs.source_type, - })), - placeholder: t('palette.search'), - allowNone: true, - noneLabel: t('targets.brightness_vs.none'), - }); } const _pIcon = (d: any) => `${d}`; @@ -333,6 +310,19 @@ function _ensureProtocolIconSelect() { _protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 }); } +function _ensureBrightnessWidget(): BindableScalarWidget { + if (!_brightnessWidget) { + _brightnessWidget = new BindableScalarWidget({ + container: document.getElementById('target-editor-brightness-container')!, + min: 0, max: 1, step: 0.05, default: 1.0, + idPrefix: 'target-editor-brightness', + valueSources: () => _cachedValueSources, + format: (v) => v.toFixed(2), + }); + } + return _brightnessWidget; +} + function _ensureFpsWidget(): BindableScalarWidget { if (!_fpsWidget) { _fpsWidget = new BindableScalarWidget({ @@ -406,7 +396,7 @@ export async function showTargetEditor(targetId: string | null = null, cloneData (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp'; _populateCssDropdown(target.color_strip_source_id || ''); - _populateBrightnessVsDropdown(bindableSourceId(target.brightness)); + _ensureBrightnessWidget().setValue(target.brightness ?? 1.0); } else if (cloneData) { // Cloning — create mode but pre-filled from clone data _editorTags = cloneData.tags || []; @@ -424,7 +414,7 @@ export async function showTargetEditor(targetId: string | null = null, cloneData (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp'; _populateCssDropdown(cloneData.color_strip_source_id || ''); - _populateBrightnessVsDropdown(bindableSourceId(cloneData.brightness)); + _ensureBrightnessWidget().setValue(cloneData.brightness ?? 1.0); } else { // Creating new target (document.getElementById('target-editor-id') as HTMLInputElement).value = ''; @@ -440,7 +430,7 @@ export async function showTargetEditor(targetId: string | null = null, cloneData (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp'; _populateCssDropdown(''); - _populateBrightnessVsDropdown(''); + _ensureBrightnessWidget().setValue(1.0); } // Entity palette selectors @@ -454,7 +444,6 @@ export async function showTargetEditor(targetId: string | null = null, cloneData window._targetAutoName = _autoGenerateTargetName; deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateSpecificSettingsVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; (document.getElementById('target-editor-css-source') as HTMLSelectElement).onchange = () => { _autoGenerateTargetName(); }; - (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).onchange = () => { _updateBrightnessThresholdVisibility(); }; if (!targetId && !cloneData) _autoGenerateTargetName(); // Show/hide conditional fields @@ -492,6 +481,7 @@ export async function closeTargetEditorModal() { export function forceCloseTargetEditorModal() { if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; } + if (_brightnessWidget) { _brightnessWidget.destroy(); _brightnessWidget = null; } if (_fpsWidget) { _fpsWidget.destroy(); _fpsWidget = null; } if (_thresholdWidget) { _thresholdWidget.destroy(); _thresholdWidget = null; } targetEditorModal.forceClose(); @@ -511,7 +501,7 @@ export async function saveTargetEditor() { const fps = _fpsWidget ? _fpsWidget.getValue() : 30; const colorStripSourceId = (document.getElementById('target-editor-css-source') as HTMLSelectElement).value; - const brightnessVsId = (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value; + const brightness = _brightnessWidget ? _brightnessWidget.getValue() : 1.0; const minBrightnessThreshold = _thresholdWidget ? _thresholdWidget.getValue() : 0; const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked; @@ -521,7 +511,7 @@ export async function saveTargetEditor() { name, device_id: deviceId, color_strip_source_id: colorStripSourceId, - brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0, + brightness, min_brightness_threshold: minBrightnessThreshold, fps, keepalive_interval: standbyInterval, @@ -530,6 +520,8 @@ export async function saveTargetEditor() { tags: _targetTagsInput ? _targetTagsInput.getValue() : [], }; + payload.target_type = 'led'; + try { let response; if (targetId) { @@ -538,7 +530,6 @@ export async function saveTargetEditor() { body: JSON.stringify(payload), }); } else { - payload.target_type = 'led'; response = await fetchWithAuth('/output-targets', { method: 'POST', body: JSON.stringify(payload), @@ -651,7 +642,7 @@ export async function loadTargetsTab() { // Group by type const ledDevices = devicesWithState; - const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled'); + const ledTargets = targetsWithState.filter((t): t is LedOutputTarget & { state?: any; metrics?: any } => t.target_type === 'led' || (t.target_type as string) === 'wled'); const haLightTargets = targetsWithState.filter(t => t.target_type === 'ha_light'); // Update tab badge with running target count @@ -969,7 +960,7 @@ function _patchTargetMetrics(target: any) { if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds); } -export function createTargetCard(target: OutputTarget & { state?: any; metrics?: any }, deviceMap: Record, colorStripSourceMap: Record, valueSourceMap: Record) { +export function createTargetCard(target: LedOutputTarget & { state?: any; metrics?: any }, deviceMap: Record, colorStripSourceMap: Record, valueSourceMap: Record) { const state = target.state || {}; const isProcessing = state.processing || false; @@ -1015,7 +1006,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?: ${ICON_FPS} ${bindableValue(target.fps, 30)} ${_protocolBadge(device, target)} ${ICON_FILM} ${cssSummary} - ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''} + ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : (bindableValue(target.brightness, 1.0) < 1.0 ? `${ICON_SUN} ${Math.round(bindableValue(target.brightness, 1.0) * 100)}%` : '')} ${bindableValue(target.min_brightness_threshold, 0) > 0 ? `${ICON_SUN_DIM} <${bindableValue(target.min_brightness_threshold, 0)} → off` : ''} ${renderTagChips(target.tags)} diff --git a/server/src/wled_controller/static/js/features/value-sources.ts b/server/src/wled_controller/static/js/features/value-sources.ts index e1fe416..7c5ba55 100644 --- a/server/src/wled_controller/static/js/features/value-sources.ts +++ b/server/src/wled_controller/static/js/features/value-sources.ts @@ -10,22 +10,29 @@ * This module manages the editor modal and API operations. */ -import { _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache } from '../core/state.ts'; +import { + _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache, + _cachedHASources, _cachedColorStripSources, gradientsCache, GradientEntity, +} from '../core/state.ts'; import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; import { - getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon, + getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon, getColorStripIcon, getHAEntityIcon, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK, ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, ICON_TRASH, + ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS, } from '../core/icons.ts'; import { wrapCard } from '../core/card-colors.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { IconSelect, showTypePicker } from '../core/icon-select.ts'; +import type { IconSelectItem } from '../core/icon-select.ts'; +import * as P from '../core/icon-paths.ts'; import { EntitySelect } from '../core/entity-palette.ts'; import { loadPictureSources } from './streams.ts'; +import { hexToRgbArray, rgbArrayToHex } from './css-gradient-editor.ts'; import type { ValueSource } from '../types.ts'; export { getValueSourceIcon }; @@ -33,6 +40,13 @@ export { getValueSourceIcon }; // ── EntitySelect instances for value source editor ── let _vsAudioSourceEntitySelect: EntitySelect | null = null; let _vsPictureSourceEntitySelect: EntitySelect | null = null; +let _vsHASourceEntitySelect: EntitySelect | null = null; +let _vsHAEntityEntitySelect: EntitySelect | null = null; +let _vsHAEntities: any[] = []; +let _vsGradientInputEntitySelect: EntitySelect | null = null; +let _vsGradientEntitySelect: EntitySelect | null = null; +let _vsCSSSourceEntitySelect: EntitySelect | null = null; +let _vsGradientEasingIconSelect: IconSelect | null = null; let _vsTagsInput: TagInput | null = null; class ValueSourceModal extends Modal { @@ -40,6 +54,13 @@ class ValueSourceModal extends Modal { onForceClose() { if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; } + if (_vsColorEasingIconSelect) { _vsColorEasingIconSelect.destroy(); _vsColorEasingIconSelect = null; } + if (_vsHASourceEntitySelect) { _vsHASourceEntitySelect.destroy(); _vsHASourceEntitySelect = null; } + if (_vsHAEntityEntitySelect) { _vsHAEntityEntitySelect.destroy(); _vsHAEntityEntitySelect = null; } + if (_vsGradientInputEntitySelect) { _vsGradientInputEntitySelect.destroy(); _vsGradientInputEntitySelect = null; } + if (_vsGradientEntitySelect) { _vsGradientEntitySelect.destroy(); _vsGradientEntitySelect = null; } + if (_vsCSSSourceEntitySelect) { _vsCSSSourceEntitySelect.destroy(); _vsCSSSourceEntitySelect = null; } + if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; } } snapshotValues() { @@ -68,6 +89,11 @@ class ValueSourceModal extends Modal { daylightSpeed: (document.getElementById('value-source-daylight-speed') as HTMLInputElement).value, daylightRealTime: (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked, daylightLatitude: (document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value, + staticColor: (document.getElementById('value-source-static-color') as HTMLInputElement).value, + animatedColors: JSON.stringify(_animatedColors), + animatedColorSpeed: (document.getElementById('value-source-animated-color-speed') as HTMLInputElement).value, + animatedColorEasing: (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value, + colorSchedule: JSON.stringify(_colorSchedulePoints), tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []), }; } @@ -101,10 +127,20 @@ function _autoGenerateVSName() { /* ── Icon-grid type selector ──────────────────────────────────── */ -const VS_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight']; +const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity']; +const VS_COLOR_TYPE_KEYS = ['static_color', 'animated_color', 'adaptive_time_color', 'gradient_map', 'css_extract']; +const VS_TYPE_KEYS = [...VS_FLOAT_TYPE_KEYS, ...VS_COLOR_TYPE_KEYS]; -function _buildVSTypeItems() { - return VS_TYPE_KEYS.map(key => ({ +let _vsTypeFilter: 'all' | 'float' | 'color' = 'all'; + +function _getFilteredTypeKeys(): string[] { + if (_vsTypeFilter === 'float') return VS_FLOAT_TYPE_KEYS; + if (_vsTypeFilter === 'color') return VS_COLOR_TYPE_KEYS; + return VS_TYPE_KEYS; +} + +function _buildVSTypeItems(): IconSelectItem[] { + return _getFilteredTypeKeys().map(key => ({ value: key, icon: getValueSourceIcon(key), label: t(`value_source.type.${key}`), @@ -112,8 +148,11 @@ function _buildVSTypeItems() { })); } +const _icon = (d: string) => `${d}`; + let _vsTypeIconSelect: IconSelect | null = null; let _waveformIconSelect: IconSelect | null = null; +let _vsColorEasingIconSelect: IconSelect | null = null; const _WAVEFORM_SVG = { sine: '', @@ -135,6 +174,17 @@ function _ensureWaveformIconSelect() { _waveformIconSelect = new IconSelect({ target: sel, items, columns: 4 } as any); } +function _ensureColorEasingIconSelect() { + const sel = document.getElementById('value-source-animated-color-easing') as HTMLSelectElement | null; + if (!sel) return; + const items = [ + { value: 'linear', icon: _icon(P.activity), label: t('value_source.animated_color.easing.linear'), desc: t('value_source.animated_color.easing.linear.desc') }, + { value: 'step', icon: _icon(P.layoutDashboard), label: t('value_source.animated_color.easing.step'), desc: t('value_source.animated_color.easing.step.desc') }, + ]; + if (_vsColorEasingIconSelect) { _vsColorEasingIconSelect.updateItems(items); return; } + _vsColorEasingIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any); +} + /* ── Waveform canvas preview ──────────────────────────────────── */ /** @@ -251,10 +301,20 @@ function _ensureVSTypeIconSelect() { export async function showValueSourceModal(editData: any, presetType: any = null) { // When creating new: show type picker first, then re-enter with presetType if (!editData && !presetType) { + _vsTypeFilter = 'all'; showTypePicker({ title: t('value_source.select_type'), items: _buildVSTypeItems(), onPick: (type) => showValueSourceModal(null, type), + filterTabs: [ + { key: 'all', label: t('value_source.filter.all') }, + { key: 'float', label: t('value_source.filter.float') }, + { key: 'color', label: t('value_source.filter.color') }, + ], + onFilterChange: (key) => { + _vsTypeFilter = key as any; + return _buildVSTypeItems(); + }, }); return; } @@ -322,6 +382,38 @@ export async function showValueSourceModal(editData: any, presetType: any = null _syncDaylightVSSpeedVisibility(); _setSlider('value-source-adaptive-min-value', editData.min_value ?? 0); _setSlider('value-source-adaptive-max-value', editData.max_value ?? 1); + } else if (editData.source_type === 'static_color') { + const rgb = editData.color || [255, 255, 255]; + (document.getElementById('value-source-static-color') as HTMLInputElement).value = rgbArrayToHex(rgb); + } else if (editData.source_type === 'animated_color') { + _animatedColors = (editData.colors || [[255, 0, 0], [0, 255, 0], [0, 0, 255]]).map((c: number[]) => rgbArrayToHex(c)); + _renderAnimatedColorList(); + _setSlider('value-source-animated-color-speed', editData.speed ?? 10.0); + (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value = editData.easing || 'linear'; + _ensureColorEasingIconSelect(); + } else if (editData.source_type === 'adaptive_time_color') { + _colorSchedulePoints = (editData.schedule || []).map((p: any) => ({ + time: p.time, + color: rgbArrayToHex(p.color || [255, 255, 255]), + })); + _renderColorScheduleList(); + } else if (editData.source_type === 'ha_entity') { + _populateHASourceDropdown(editData.ha_source_id || ''); + await _fetchVSHAEntities(editData.ha_source_id || ''); + _populateHAEntityDropdown(editData.entity_id || ''); + (document.getElementById('value-source-attribute') as HTMLInputElement).value = editData.attribute || ''; + (document.getElementById('value-source-min-ha-value') as HTMLInputElement).value = String(editData.min_ha_value ?? 0); + (document.getElementById('value-source-max-ha-value') as HTMLInputElement).value = String(editData.max_ha_value ?? 100); + _setSlider('value-source-ha-smoothing', editData.smoothing ?? 0); + } else if (editData.source_type === 'gradient_map') { + _populateGradientInputDropdown(editData.value_source_id || ''); + _populateGradientEntityDropdown(editData.gradient_id || ''); + (document.getElementById('value-source-gradient-easing') as HTMLSelectElement).value = editData.easing || 'linear'; + _ensureGradientEasingIconSelect(); + } else if (editData.source_type === 'css_extract') { + _populateCSSSourceDropdown(editData.color_strip_source_id || ''); + (document.getElementById('value-source-led-start') as HTMLInputElement).value = String(editData.led_start ?? 0); + (document.getElementById('value-source-led-end') as HTMLInputElement).value = String(editData.led_end ?? -1); } } else { (document.getElementById('value-source-name') as HTMLInputElement).value = ''; @@ -355,6 +447,25 @@ export async function showValueSourceModal(editData: any, presetType: any = null (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked = false; _setSlider('value-source-daylight-latitude', 50); _syncDaylightVSSpeedVisibility(); + // Color type defaults + (document.getElementById('value-source-static-color') as HTMLInputElement).value = '#ffffff'; + _animatedColors = ['#ff0000', '#00ff00', '#0000ff']; + _renderAnimatedColorList(); + _setSlider('value-source-animated-color-speed', 10.0); + (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value = 'linear'; + _colorSchedulePoints = []; + _renderColorScheduleList(); + // HA entity defaults + (document.getElementById('value-source-entity-id') as HTMLInputElement).value = ''; + (document.getElementById('value-source-attribute') as HTMLInputElement).value = ''; + (document.getElementById('value-source-min-ha-value') as HTMLInputElement).value = '0'; + (document.getElementById('value-source-max-ha-value') as HTMLInputElement).value = '100'; + _setSlider('value-source-ha-smoothing', 0); + // Gradient map defaults + (document.getElementById('value-source-gradient-easing') as HTMLSelectElement).value = 'linear'; + // CSS extract defaults + (document.getElementById('value-source-led-start') as HTMLInputElement).value = '0'; + (document.getElementById('value-source-led-end') as HTMLInputElement).value = '-1'; _autoGenerateVSName(); } @@ -387,6 +498,13 @@ export function onValueSourceTypeChange() { (document.getElementById('value-source-adaptive-time-section') as HTMLElement).style.display = type === 'adaptive_time' ? '' : 'none'; (document.getElementById('value-source-adaptive-scene-section') as HTMLElement).style.display = type === 'adaptive_scene' ? '' : 'none'; (document.getElementById('value-source-daylight-section') as HTMLElement).style.display = type === 'daylight' ? '' : 'none'; + (document.getElementById('value-source-static-color-section') as HTMLElement).style.display = type === 'static_color' ? '' : 'none'; + (document.getElementById('value-source-animated-color-section') as HTMLElement).style.display = type === 'animated_color' ? '' : 'none'; + if (type === 'animated_color') _ensureColorEasingIconSelect(); + (document.getElementById('value-source-adaptive-time-color-section') as HTMLElement).style.display = type === 'adaptive_time_color' ? '' : 'none'; + (document.getElementById('value-source-ha-entity-section') as HTMLElement).style.display = type === 'ha_entity' ? '' : 'none'; + (document.getElementById('value-source-gradient-map-section') as HTMLElement).style.display = type === 'gradient_map' ? '' : 'none'; + (document.getElementById('value-source-css-extract-section') as HTMLElement).style.display = type === 'css_extract' ? '' : 'none'; (document.getElementById('value-source-adaptive-range-section') as HTMLElement).style.display = (type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none'; @@ -403,6 +521,28 @@ export function onValueSourceTypeChange() { _populatePictureSourceDropdown(''); } + // Populate HA source dropdown when switching to ha_entity type + if (type === 'ha_entity') { + _populateHASourceDropdown(''); + // Auto-fetch entities for the pre-selected HA source + const haSelect = document.getElementById('value-source-ha-source') as HTMLSelectElement; + if (haSelect?.value) { + _fetchVSHAEntities(haSelect.value).then(() => _populateHAEntityDropdown('')); + } + } + + // Populate gradient input and gradient entity dropdowns when switching to gradient_map type + if (type === 'gradient_map') { + _populateGradientInputDropdown(''); + _populateGradientEntityDropdown(''); + _ensureGradientEasingIconSelect(); + } + + // Populate CSS source dropdown when switching to css_extract type + if (type === 'css_extract') { + _populateCSSSourceDropdown(''); + } + _autoGenerateVSName(); } @@ -471,6 +611,55 @@ export async function saveValueSource() { payload.latitude = parseFloat((document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value); payload.min_value = parseFloat((document.getElementById('value-source-adaptive-min-value') as HTMLInputElement).value); payload.max_value = parseFloat((document.getElementById('value-source-adaptive-max-value') as HTMLInputElement).value); + } else if (sourceType === 'static_color') { + payload.color = hexToRgbArray((document.getElementById('value-source-static-color') as HTMLInputElement).value); + } else if (sourceType === 'animated_color') { + payload.colors = _getAnimatedColorsPayload(); + payload.speed = parseFloat((document.getElementById('value-source-animated-color-speed') as HTMLInputElement).value); + payload.easing = (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value; + } else if (sourceType === 'adaptive_time_color') { + payload.schedule = _getColorSchedulePayload(); + } else if (sourceType === 'ha_entity') { + payload.ha_source_id = (document.getElementById('value-source-ha-source') as HTMLSelectElement).value; + payload.entity_id = (document.getElementById('value-source-entity-id') as HTMLSelectElement).value; + payload.attribute = (document.getElementById('value-source-attribute') as HTMLInputElement).value.trim(); + payload.min_ha_value = parseFloat((document.getElementById('value-source-min-ha-value') as HTMLInputElement).value); + payload.max_ha_value = parseFloat((document.getElementById('value-source-max-ha-value') as HTMLInputElement).value); + payload.smoothing = parseFloat((document.getElementById('value-source-ha-smoothing') as HTMLInputElement).value); + if (!payload.ha_source_id) { + errorEl.textContent = t('value_source.ha_source') + ' required'; + errorEl.style.display = ''; + return; + } + if (!payload.entity_id) { + errorEl.textContent = t('value_source.entity_id') + ' required'; + errorEl.style.display = ''; + return; + } + } else if (sourceType === 'gradient_map') { + payload.value_source_id = (document.getElementById('value-source-gradient-input') as HTMLSelectElement).value; + payload.gradient_id = (document.getElementById('value-source-gradient-id') as HTMLSelectElement).value; + payload.easing = (document.getElementById('value-source-gradient-easing') as HTMLSelectElement).value; + if (!payload.value_source_id) { + errorEl.textContent = t('value_source.input_source') + ' required'; + errorEl.style.display = ''; + return; + } + if (!payload.gradient_id) { + errorEl.textContent = t('value_source.gradient_stops') + ' required'; + errorEl.style.display = ''; + return; + } + } else if (sourceType === 'css_extract') { + payload.color_strip_source_id = (document.getElementById('value-source-css-source') as HTMLSelectElement).value; + payload.led_start = parseInt((document.getElementById('value-source-led-start') as HTMLInputElement).value) || 0; + payload.led_end = parseInt((document.getElementById('value-source-led-end') as HTMLInputElement).value); + if (isNaN(payload.led_end)) payload.led_end = -1; + if (!payload.color_strip_source_id) { + errorEl.textContent = t('value_source.css_source') + ' required'; + errorEl.style.display = ''; + return; + } } try { @@ -799,6 +988,54 @@ export function createValueSourceCard(src: ValueSource) { ${psBadge} ${ICON_REFRESH} ${src.scene_behavior || 'complement'} `; + } else if (src.source_type === 'static_color') { + const rgb = (src as any).color || [255, 255, 255]; + const hex = rgbArrayToHex(rgb); + propsHtml = ` ${hex}`; + } else if (src.source_type === 'animated_color') { + const colors = (src as any).colors || []; + propsHtml = ` + ${ICON_ACTIVITY} ${colors.length} ${t('value_source.animated_color.color_count')} + ${ICON_TIMER} ${(src as any).speed ?? 10} cpm + `; + } else if (src.source_type === 'adaptive_time_color') { + const pts = ((src as any).schedule || []).length; + propsHtml = `${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}`; + } else if (src.source_type === 'ha_entity') { + const haSrc = _cachedHASources.find(h => h.id === (src as any).ha_source_id); + const haName = haSrc ? haSrc.name : ((src as any).ha_source_id || '-'); + const entityId = (src as any).entity_id || ''; + const attr = (src as any).attribute; + propsHtml = ` + ${ICON_HOME} ${escapeHtml(haName)} + ${ICON_LINK} ${escapeHtml(entityId)}${attr ? '.' + escapeHtml(attr) : ''} + ${ICON_MOVE_VERTICAL} ${(src as any).min_ha_value ?? 0}\u2013${(src as any).max_ha_value ?? 100} + `; + } else if (src.source_type === 'gradient_map') { + const inputVs = _cachedValueSources.find(v => v.id === (src as any).value_source_id); + const inputName = inputVs ? inputVs.name : ((src as any).value_source_id || '-'); + const gradients = gradientsCache.data || []; + const grad = gradients.find(g => g.id === (src as any).gradient_id); + const gradName = grad ? grad.name : ((src as any).gradient_id || '-'); + const stops = grad?.stops || []; + const gradientCss = stops.length >= 2 + ? `linear-gradient(to right, ${stops.map((s: any) => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ')})` + : '#333'; + propsHtml = ` + ${ICON_LINK} ${escapeHtml(inputName)} + ${ICON_RAINBOW} ${escapeHtml(gradName)} +
+ `; + } else if (src.source_type === 'css_extract') { + const cssSrc = _cachedColorStripSources.find(c => c.id === (src as any).color_strip_source_id); + const cssName = cssSrc ? cssSrc.name : ((src as any).color_strip_source_id || '-'); + const ledStart = (src as any).led_start ?? 0; + const ledEnd = (src as any).led_end ?? -1; + const rangeLabel = ledEnd < 0 ? `${ledStart}\u2013all` : `${ledStart}\u2013${ledEnd}`; + propsHtml = ` + ${ICON_DROPLETS} ${escapeHtml(cssName)} + ${ICON_MOVE_VERTICAL} LED ${rangeLabel} + `; } return wrapCard({ @@ -973,3 +1210,236 @@ function _populateScheduleUI(schedule: any) { schedule.forEach(p => addSchedulePoint(p.time, p.value)); } } + +// ── Animated Color helpers ────────────────────────────────── + +let _animatedColors: string[] = ['#ff0000', '#00ff00', '#0000ff']; + +export function addAnimatedColor(color: string = '#ffffff') { + _animatedColors = [..._animatedColors, color]; + _renderAnimatedColorList(); +} + +export function removeAnimatedColor(idx: number) { + _animatedColors = _animatedColors.filter((_, i) => i !== idx); + _renderAnimatedColorList(); +} + +function _renderAnimatedColorList() { + const list = document.getElementById('value-source-animated-color-list'); + if (!list) return; + list.innerHTML = _animatedColors.map((c, i) => ` +
+ + +
+ `).join(''); + // Wire up color inputs to update state immutably + list.querySelectorAll('.animated-color-input').forEach((el) => { + const input = el as HTMLInputElement; + const idx = parseInt(input.dataset.idx || '0', 10); + input.addEventListener('input', () => { + _animatedColors = _animatedColors.map((c, i) => i === idx ? input.value : c); + }); + }); +} + +function _getAnimatedColorsPayload(): number[][] { + return _animatedColors.map(c => hexToRgbArray(c)); +} + +// ── Color Schedule helpers ────────────────────────────────── + +let _colorSchedulePoints: { time: string; color: string }[] = []; + +export function addColorSchedulePoint(time: string = '12:00', color: string = '#ffffff') { + _colorSchedulePoints = [..._colorSchedulePoints, { time, color }]; + _renderColorScheduleList(); +} + +export function removeColorSchedulePoint(idx: number) { + _colorSchedulePoints = _colorSchedulePoints.filter((_, i) => i !== idx); + _renderColorScheduleList(); +} + +function _renderColorScheduleList() { + const list = document.getElementById('value-source-color-schedule-list'); + if (!list) return; + list.innerHTML = _colorSchedulePoints.map((p, i) => ` +
+ + + +
+ `).join(''); + list.querySelectorAll('.color-schedule-time').forEach((el) => { + const input = el as HTMLInputElement; + const idx = parseInt(input.dataset.idx || '0', 10); + input.addEventListener('input', () => { + _colorSchedulePoints = _colorSchedulePoints.map((p, i) => i === idx ? { ...p, time: input.value } : p); + }); + }); + list.querySelectorAll('.color-schedule-color').forEach((el) => { + const input = el as HTMLInputElement; + const idx = parseInt(input.dataset.idx || '0', 10); + input.addEventListener('input', () => { + _colorSchedulePoints = _colorSchedulePoints.map((p, i) => i === idx ? { ...p, color: input.value } : p); + }); + }); +} + +function _getColorSchedulePayload(): { time: string; color: number[] }[] { + return _colorSchedulePoints.map(p => ({ time: p.time, color: hexToRgbArray(p.color) })); +} + +// ── HA Entity helpers ────────────────────────────────────── + +function _populateHASourceDropdown(selectedId: string) { + const select = document.getElementById('value-source-ha-source') as HTMLSelectElement; + if (!select) return; + select.innerHTML = _cachedHASources.map((s: any) => + `` + ).join(''); + + if (_vsHASourceEntitySelect) _vsHASourceEntitySelect.destroy(); + if (_cachedHASources.length > 0) { + _vsHASourceEntitySelect = new EntitySelect({ + target: select, + getItems: () => _cachedHASources.map(s => ({ + value: s.id, + label: s.name, + icon: ICON_HOME, + desc: s.host || '', + })), + placeholder: t('palette.search'), + onChange: (value) => { + _fetchVSHAEntities(value).then(() => _populateHAEntityDropdown('')); + }, + }); + } +} + +async function _fetchVSHAEntities(haSourceId: string): Promise { + if (!haSourceId) { _vsHAEntities = []; return; } + try { + const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`); + if (!resp.ok) { _vsHAEntities = []; return; } + const data = await resp.json(); + _vsHAEntities = data.entities || []; + } catch { + _vsHAEntities = []; + } +} + +function _populateHAEntityDropdown(selectedId: string) { + const select = document.getElementById('value-source-entity-id') as HTMLSelectElement; + if (!select) return; + select.innerHTML = _vsHAEntities.map((e: any) => + `` + ).join(''); + + if (_vsHAEntityEntitySelect) _vsHAEntityEntitySelect.destroy(); + if (_vsHAEntities.length > 0) { + _vsHAEntityEntitySelect = new EntitySelect({ + target: select, + getItems: () => _vsHAEntities.map((e: any) => ({ + value: e.entity_id, + label: e.friendly_name || e.entity_id, + icon: getHAEntityIcon(e), + desc: e.entity_id, + })), + placeholder: t('palette.search'), + }); + } +} + +// ── Gradient Map helpers ─────────────────────────────────── + +function _populateGradientInputDropdown(selectedId: string) { + const select = document.getElementById('value-source-gradient-input') as HTMLSelectElement; + if (!select) return; + const floatSources = _cachedValueSources.filter(v => v.return_type === 'float'); + select.innerHTML = floatSources.map((s: any) => + `` + ).join(''); + + if (_vsGradientInputEntitySelect) _vsGradientInputEntitySelect.destroy(); + if (floatSources.length > 0) { + _vsGradientInputEntitySelect = new EntitySelect({ + target: select, + getItems: () => floatSources.map(s => ({ + value: s.id, + label: s.name, + icon: getValueSourceIcon(s.source_type), + desc: s.source_type, + })), + placeholder: t('palette.search'), + }); + } +} + +function _ensureGradientEasingIconSelect() { + const sel = document.getElementById('value-source-gradient-easing') as HTMLSelectElement | null; + if (!sel) return; + const items = [ + { value: 'linear', icon: _icon(P.activity), label: 'Linear', desc: 'Smooth blend' }, + { value: 'step', icon: _icon(P.layoutDashboard), label: 'Step', desc: 'Hard edges' }, + ]; + if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.updateItems(items); return; } + _vsGradientEasingIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any); +} + +function _populateGradientEntityDropdown(selectedId: string) { + const select = document.getElementById('value-source-gradient-id') as HTMLSelectElement; + if (!select) return; + const gradients = gradientsCache.data || []; + select.innerHTML = gradients.map((g: GradientEntity) => + `` + ).join(''); + + if (_vsGradientEntitySelect) _vsGradientEntitySelect.destroy(); + if (gradients.length > 0) { + _vsGradientEntitySelect = new EntitySelect({ + target: select, + getItems: () => (gradientsCache.data || []).map((g: GradientEntity) => { + const stops = g.stops || []; + const stripHtml = stops.length >= 2 + ? `` + : ICON_RAINBOW; + return { + value: g.id, + label: g.name, + icon: stripHtml, + desc: `${stops.length} stops`, + }; + }), + placeholder: t('palette.search'), + }); + } + +} + +// ── CSS Extract helpers ──────────────────────────────────── + +function _populateCSSSourceDropdown(selectedId: string) { + const select = document.getElementById('value-source-css-source') as HTMLSelectElement; + if (!select) return; + select.innerHTML = _cachedColorStripSources.map((s: any) => + `` + ).join(''); + + if (_vsCSSSourceEntitySelect) _vsCSSSourceEntitySelect.destroy(); + if (_cachedColorStripSources.length > 0) { + _vsCSSSourceEntitySelect = new EntitySelect({ + target: select, + getItems: () => _cachedColorStripSources.map(s => ({ + value: s.id, + label: s.name, + icon: getColorStripIcon(s.source_type), + desc: s.source_type, + })), + placeholder: t('palette.search'), + }); + } +} diff --git a/server/src/wled_controller/static/js/global.d.ts b/server/src/wled_controller/static/js/global.d.ts index 2947895..0056cba 100644 --- a/server/src/wled_controller/static/js/global.d.ts +++ b/server/src/wled_controller/static/js/global.d.ts @@ -23,7 +23,7 @@ interface Window { // ─── Visual effects (called from inline