feat: new value source types (HA entity, gradient map, strip extract) + UI fixes
Lint & Test / test (push) Successful in 1m27s

New value source types:
- ha_entity: reads numeric values from HA entity state/attribute, normalizes
  via min/max range, applies EMA smoothing. EntitySelect for HA connection
  and entity selection with live entity list fetching.
- gradient_map: maps a float value source (0-1) through a gradient entity.
  EntitySelect for both input source and gradient with inline previews.
- css_extract: extracts single color by averaging LED range from a color
  strip source. EntitySelect for source selection.

Value source type picker:
- Filter tabs (All / Numeric / Color) above the icon grid
- showTypePicker extended with filterTabs + onFilterChange support

Palette selectors converted to EntitySelect:
- Effect palette, gradient preset, and audio palette selectors now use
  command-palette style EntitySelect with gradient strip previews

Tab indicator fixes:
- Icon now updates on tab switch (was passing no args to updateTabIndicator)
- Visible with any background effect active, not just Noise Field
- Noise Field is the default background effect for new users

Dashboard section collapse fix:
- Split header into clickable toggle (chevron+label) and non-clickable
  actions area — buttons no longer trigger collapse/expand

Discriminated union fix (422 errors):
- source_type/target_type now always included in update payloads for:
  CSS editor, LED target, HA light target, simple calibration,
  advanced calibration
This commit is contained in:
2026-03-29 20:38:22 +03:00
parent ea812bb4d5
commit 384362ccf1
61 changed files with 5367 additions and 1620 deletions
+222 -29
View File
@@ -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
@@ -1,9 +1,9 @@
"""Audio source routes: CRUD for audio sources + real-time test WebSocket."""
import asyncio
from typing import Optional
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from starlette.websockets import WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
@@ -19,8 +19,16 @@ from wled_controller.api.schemas.audio_sources import (
AudioSourceListResponse,
AudioSourceResponse,
AudioSourceUpdate,
BandExtractAudioSourceResponse,
MonoAudioSourceResponse,
MultichannelAudioSourceResponse,
)
from wled_controller.storage.audio_source import (
AudioSource,
BandExtractAudioSource,
MonoAudioSource,
MultichannelAudioSource,
)
from wled_controller.storage.audio_source import AudioSource
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.utils import get_logger
@@ -31,31 +39,68 @@ logger = get_logger(__name__)
router = APIRouter()
_RESPONSE_MAP = {
MultichannelAudioSource: lambda s: MultichannelAudioSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
device_index=s.device_index,
is_loopback=s.is_loopback,
audio_template_id=s.audio_template_id,
),
MonoAudioSource: lambda s: MonoAudioSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
audio_source_id=s.audio_source_id,
channel=s.channel,
),
BandExtractAudioSource: lambda s: BandExtractAudioSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
audio_source_id=s.audio_source_id,
band=s.band,
freq_low=s.freq_low,
freq_high=s.freq_high,
),
}
def _to_response(source: AudioSource) -> AudioSourceResponse:
"""Convert an AudioSource to an AudioSourceResponse."""
return AudioSourceResponse(
id=source.id,
name=source.name,
source_type=source.source_type,
device_index=getattr(source, "device_index", None),
is_loopback=getattr(source, "is_loopback", None),
audio_template_id=getattr(source, "audio_template_id", None),
audio_source_id=getattr(source, "audio_source_id", None),
channel=getattr(source, "channel", None),
band=getattr(source, "band", None),
freq_low=getattr(source, "freq_low", None),
freq_high=getattr(source, "freq_high", None),
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
)
"""Convert an AudioSource dataclass to the matching response schema."""
builder = _RESPONSE_MAP.get(type(source))
if builder is None:
# Fallback for unknown types — return as multichannel
return MultichannelAudioSourceResponse(
id=source.id,
name=source.name,
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
device_index=getattr(source, "device_index", -1),
is_loopback=getattr(source, "is_loopback", True),
audio_template_id=getattr(source, "audio_template_id", None),
)
return builder(source)
@router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"])
async def list_audio_sources(
_auth: AuthRequired,
source_type: Optional[str] = Query(None, description="Filter by source_type: multichannel, mono, or band_extract"),
source_type: Optional[str] = Query(
None, description="Filter by source_type: multichannel, mono, or band_extract"
),
store: AudioSourceStore = Depends(get_audio_source_store),
):
"""List all audio sources, optionally filtered by type."""
@@ -68,27 +113,26 @@ async def list_audio_sources(
)
@router.post("/api/v1/audio-sources", response_model=AudioSourceResponse, status_code=201, tags=["Audio Sources"])
@router.post(
"/api/v1/audio-sources",
response_model=AudioSourceResponse,
status_code=201,
tags=["Audio Sources"],
)
async def create_audio_source(
data: AudioSourceCreate,
data: Annotated[AudioSourceCreate, Body(discriminator="source_type")],
_auth: AuthRequired,
store: AudioSourceStore = Depends(get_audio_source_store),
):
"""Create a new audio source."""
try:
fields = data.model_dump(exclude={"source_type", "name", "description", "tags"})
source = store.create_source(
name=data.name,
source_type=data.source_type,
device_index=data.device_index,
is_loopback=data.is_loopback,
audio_source_id=data.audio_source_id,
channel=data.channel,
description=data.description,
audio_template_id=data.audio_template_id,
tags=data.tags,
band=data.band,
freq_low=data.freq_low,
freq_high=data.freq_high,
**fields,
)
fire_entity_event("audio_source", "created", source.id)
return _to_response(source)
@@ -99,7 +143,9 @@ async def create_audio_source(
raise HTTPException(status_code=400, detail=str(e))
@router.get("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
@router.get(
"/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"]
)
async def get_audio_source(
source_id: str,
_auth: AuthRequired,
@@ -113,29 +159,19 @@ async def get_audio_source(
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
@router.put(
"/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"]
)
async def update_audio_source(
source_id: str,
data: AudioSourceUpdate,
data: Annotated[AudioSourceUpdate, Body(discriminator="source_type")],
_auth: AuthRequired,
store: AudioSourceStore = Depends(get_audio_source_store),
):
"""Update an existing audio source."""
try:
source = store.update_source(
source_id=source_id,
name=data.name,
device_index=data.device_index,
is_loopback=data.is_loopback,
audio_source_id=data.audio_source_id,
channel=data.channel,
description=data.description,
audio_template_id=data.audio_template_id,
tags=data.tags,
band=data.band,
freq_low=data.freq_low,
freq_high=data.freq_high,
)
fields = data.model_dump(exclude={"source_type"}, exclude_none=True)
source = store.update_source(source_id=source_id, **fields)
fire_entity_event("audio_source", "updated", source_id)
return _to_response(source)
except EntityNotFoundError as e:
@@ -156,11 +192,13 @@ async def delete_audio_source(
try:
# Check if any CSS entities reference this audio source
from wled_controller.storage.color_strip_source import AudioColorStripSource
for css in css_store.get_all_sources():
if isinstance(css, AudioColorStripSource) and getattr(css, "audio_source_id", None) == source_id:
raise ValueError(
f"Cannot delete: referenced by color strip source '{css.name}'"
)
if (
isinstance(css, AudioColorStripSource)
and getattr(css, "audio_source_id", None) == source_id
):
raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'")
store.delete_source(source_id)
fire_entity_event("audio_source", "deleted", source_id)
@@ -187,6 +225,7 @@ async def test_audio_source_ws(
snapshots as JSON at ~20 Hz.
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -211,6 +250,7 @@ async def test_audio_source_ws(
band_mask = None
if resolved.freq_low is not None and resolved.freq_high is not None:
from wled_controller.core.audio.band_filter import compute_band_mask
band_mask = compute_band_mask(resolved.freq_low, resolved.freq_high)
# Resolve template → engine_type + config
@@ -257,15 +297,18 @@ async def test_audio_source_ws(
# Apply band filter if present
if band_mask is not None:
from wled_controller.core.audio.band_filter import apply_band_filter
spectrum, rms = apply_band_filter(spectrum, rms, band_mask)
await websocket.send_json({
"spectrum": spectrum.tolist(),
"rms": round(rms, 4),
"peak": round(analysis.peak, 4),
"beat": analysis.beat,
"beat_intensity": round(analysis.beat_intensity, 4),
})
await websocket.send_json(
{
"spectrum": spectrum.tolist(),
"rms": round(rms, 4),
"peak": round(analysis.peak, 4),
"beat": analysis.beat,
"beat_intensity": round(analysis.beat_intensity, 4),
}
)
await asyncio.sleep(0.05)
except WebSocketDisconnect:
@@ -4,9 +4,10 @@ import asyncio
import json as _json
import time as _time
import uuid as _uuid
from typing import Annotated
import numpy as np
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
@@ -20,13 +21,30 @@ from wled_controller.api.dependencies import (
get_template_store,
)
from wled_controller.api.schemas.color_strip_sources import (
ApiInputCSSResponse,
AudioCSSResponse,
CandlelightCSSResponse,
ColorCycleCSSResponse,
ColorPushRequest,
ColorStop as ColorStopSchema,
ColorStripSourceCreate,
ColorStripSourceListResponse,
ColorStripSourceResponse,
ColorStripSourceUpdate,
CompositeCSSResponse,
CSSCalibrationTestRequest,
DaylightCSSResponse,
EffectCSSResponse,
GradientCSSResponse,
KeyColorsCSSResponse,
MappedCSSResponse,
NotificationCSSResponse,
NotifyRequest,
PictureAdvancedCSSResponse,
PictureCSSResponse,
ProcessedCSSResponse,
StaticCSSResponse,
WeatherCSSResponse,
)
from wled_controller.api.schemas.devices import (
Calibration as CalibrationSchema,
@@ -34,15 +52,27 @@ from wled_controller.api.schemas.devices import (
)
from wled_controller.core.capture.calibration import (
calibration_from_dict,
calibration_to_dict,
)
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.color_strip_source import (
AdvancedPictureColorStripSource,
ApiInputColorStripSource,
AudioColorStripSource,
CandlelightColorStripSource,
ColorCycleColorStripSource,
CompositeColorStripSource,
DaylightColorStripSource,
EffectColorStripSource,
GradientColorStripSource,
KeyColorsColorStripSource,
MappedColorStripSource,
NotificationColorStripSource,
PictureColorStripSource,
ProcessedColorStripSource,
StaticColorStripSource,
WeatherColorStripSource,
)
from wled_controller.storage import DeviceStore
from wled_controller.storage.color_strip_store import ColorStripStore
@@ -61,50 +91,179 @@ logger = get_logger(__name__)
router = APIRouter()
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
"""Convert a ColorStripSource to a ColorStripSourceResponse.
Uses the source's to_dict() for type-specific fields, then applies
schema conversions for calibration and gradient stops.
"""
from wled_controller.api.schemas.color_strip_sources import ColorStop as ColorStopSchema
d = source.to_dict()
# Convert calibration dict → schema object
calibration = None
raw_cal = d.pop("calibration", None)
if raw_cal and isinstance(raw_cal, dict):
calibration = CalibrationSchema(**raw_cal)
# Convert stop dicts → schema objects
raw_stops = d.pop("stops", None)
stops = None
if raw_stops is not None:
try:
stops = [ColorStopSchema(**s) for s in raw_stops]
except Exception:
stops = None
# Remove serialized timestamp strings — use actual datetime objects
d.pop("created_at", None)
d.pop("updated_at", None)
# Filter to only keys accepted by the schema (to_dict may include extra
# fields like 'fps' that aren't in the response model)
valid_fields = ColorStripSourceResponse.model_fields
filtered = {k: v for k, v in d.items() if k in valid_fields}
return ColorStripSourceResponse(
**filtered,
calibration=calibration,
stops=stops,
def _common_response_kwargs(source, overlay_active: bool = False) -> dict:
"""Shared response fields from any ColorStripSource."""
return dict(
id=source.id,
name=source.name,
description=source.description,
led_count=getattr(source, "led_count", 0),
overlay_active=overlay_active,
clock_id=source.clock_id,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
)
def _calibration_schema(source) -> CalibrationSchema | None:
"""Convert a source's calibration to a schema object, or None."""
cal = getattr(source, "calibration", None)
if cal is None:
return None
try:
return CalibrationSchema(**calibration_to_dict(cal))
except Exception:
return None
def _stops_schema(source) -> list[ColorStopSchema] | None:
"""Convert a source's stops list to schema objects, or None."""
raw = getattr(source, "stops", None)
if raw is None:
return None
try:
return [ColorStopSchema(**dict(s)) for s in raw]
except Exception:
return None
# Maps storage class → response builder lambda.
_RESPONSE_MAP: dict = {
PictureColorStripSource: lambda s, kw: PictureCSSResponse(
**kw,
picture_source_id=s.picture_source_id,
smoothing=s.smoothing.to_dict(),
interpolation_mode=s.interpolation_mode,
calibration=_calibration_schema(s),
),
AdvancedPictureColorStripSource: lambda s, kw: PictureAdvancedCSSResponse(
**kw,
smoothing=s.smoothing.to_dict(),
interpolation_mode=s.interpolation_mode,
calibration=_calibration_schema(s),
),
StaticColorStripSource: lambda s, kw: StaticCSSResponse(
**kw,
color=s.color.to_dict(),
animation=s.animation,
),
GradientColorStripSource: lambda s, kw: GradientCSSResponse(
**kw,
stops=_stops_schema(s),
animation=s.animation,
easing=s.easing,
gradient_id=s.gradient_id,
),
ColorCycleColorStripSource: lambda s, kw: ColorCycleCSSResponse(
**kw,
colors=[list(c) for c in s.colors],
),
EffectColorStripSource: lambda s, kw: EffectCSSResponse(
**kw,
effect_type=s.effect_type,
palette=s.palette,
gradient_id=s.gradient_id,
color=s.color.to_dict(),
intensity=s.intensity.to_dict(),
scale=s.scale.to_dict(),
mirror=s.mirror,
custom_palette=s.custom_palette,
),
CompositeColorStripSource: lambda s, kw: CompositeCSSResponse(
**kw,
layers=[dict(layer) for layer in s.layers],
),
MappedColorStripSource: lambda s, kw: MappedCSSResponse(
**kw,
zones=[dict(z) for z in s.zones],
),
AudioColorStripSource: lambda s, kw: AudioCSSResponse(
**kw,
visualization_mode=s.visualization_mode,
audio_source_id=s.audio_source_id,
sensitivity=s.sensitivity.to_dict(),
smoothing=s.smoothing.to_dict(),
palette=s.palette,
gradient_id=s.gradient_id,
color=s.color.to_dict(),
color_peak=s.color_peak.to_dict(),
mirror=s.mirror,
),
ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse(
**kw,
fallback_color=s.fallback_color.to_dict(),
timeout=s.timeout.to_dict(),
interpolation=s.interpolation,
),
NotificationColorStripSource: lambda s, kw: NotificationCSSResponse(
**kw,
notification_effect=s.notification_effect,
duration_ms=s.duration_ms.to_dict(),
default_color=s.default_color.to_dict(),
app_colors=dict(s.app_colors),
app_filter_mode=s.app_filter_mode,
app_filter_list=list(s.app_filter_list),
os_listener=s.os_listener,
sound_asset_id=s.sound_asset_id,
sound_volume=s.sound_volume.to_dict(),
app_sounds=dict(s.app_sounds),
),
DaylightColorStripSource: lambda s, kw: DaylightCSSResponse(
**kw,
speed=s.speed.to_dict(),
use_real_time=s.use_real_time,
latitude=s.latitude,
longitude=s.longitude,
),
CandlelightColorStripSource: lambda s, kw: CandlelightCSSResponse(
**kw,
color=s.color.to_dict(),
intensity=s.intensity.to_dict(),
num_candles=s.num_candles,
speed=s.speed.to_dict(),
wind_strength=s.wind_strength.to_dict(),
candle_type=s.candle_type,
),
ProcessedColorStripSource: lambda s, kw: ProcessedCSSResponse(
**kw,
input_source_id=s.input_source_id,
processing_template_id=s.processing_template_id,
),
WeatherColorStripSource: lambda s, kw: WeatherCSSResponse(
**kw,
weather_source_id=s.weather_source_id,
speed=s.speed.to_dict(),
temperature_influence=s.temperature_influence.to_dict(),
),
KeyColorsColorStripSource: lambda s, kw: KeyColorsCSSResponse(
**kw,
picture_source_id=s.picture_source_id,
rectangles=[r.to_dict() for r in s.rectangles],
interpolation_mode=s.interpolation_mode,
smoothing=s.smoothing.to_dict(),
brightness=s.brightness.to_dict(),
),
}
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
"""Convert a ColorStripSource to the matching per-type response schema."""
kw = _common_response_kwargs(source, overlay_active)
builder = _RESPONSE_MAP.get(type(source))
if builder is None:
# Fallback: use to_dict() and build a PictureCSSResponse
logger.warning("No response builder for %s, falling back", type(source).__name__)
return PictureCSSResponse(
**kw,
picture_source_id="",
smoothing=0.3,
interpolation_mode="average",
calibration=None,
)
return builder(source, kw)
def _resolve_display_index(
picture_source_id: str, picture_source_store: PictureSourceStore, depth: int = 0
) -> int:
@@ -150,22 +309,37 @@ def _extract_css_kwargs(data) -> dict:
Converts nested Pydantic models (calibration, stops, layers, zones,
animation) to plain dicts/lists that the store expects.
"""
kwargs = data.model_dump(
exclude_unset=False, exclude={"calibration", "stops", "layers", "zones", "animation"}
)
# Remove fields that don't map to store kwargs
kwargs.pop("source_type", None)
# Exclude nested models that need special conversion
exclude_fields = {"source_type"}
for nested in ("calibration", "stops", "layers", "zones", "animation"):
if hasattr(data, nested):
exclude_fields.add(nested)
kwargs = data.model_dump(exclude_unset=False, exclude=exclude_fields)
# Convert nested Pydantic models → plain dicts for the store
if hasattr(data, "calibration"):
cal = getattr(data, "calibration", None)
if cal is not None:
kwargs["calibration"] = calibration_from_dict(cal.model_dump())
else:
kwargs["calibration"] = None
if hasattr(data, "stops"):
stops = getattr(data, "stops", None)
kwargs["stops"] = [s.model_dump() for s in stops] if stops is not None else None
if hasattr(data, "layers"):
layers = getattr(data, "layers", None)
kwargs["layers"] = [layer.model_dump() for layer in layers] if layers is not None else None
if hasattr(data, "zones"):
zones = getattr(data, "zones", None)
kwargs["zones"] = [z.model_dump() for z in zones] if zones is not None else None
if hasattr(data, "animation"):
anim = getattr(data, "animation", None)
kwargs["animation"] = anim.model_dump() if anim else None
if data.calibration is not None:
kwargs["calibration"] = calibration_from_dict(data.calibration.model_dump())
else:
kwargs["calibration"] = None
kwargs["stops"] = [s.model_dump() for s in data.stops] if data.stops is not None else None
kwargs["layers"] = (
[layer.model_dump() for layer in data.layers] if data.layers is not None else None
)
kwargs["zones"] = [z.model_dump() for z in data.zones] if data.zones is not None else None
kwargs["animation"] = data.animation.model_dump() if data.animation else None
return kwargs
@@ -176,7 +350,7 @@ def _extract_css_kwargs(data) -> dict:
status_code=201,
)
async def create_color_strip_source(
data: ColorStripSourceCreate,
data: Annotated[ColorStripSourceCreate, Body(discriminator="source_type")],
_auth: AuthRequired,
store: ColorStripStore = Depends(get_color_strip_store),
):
@@ -223,7 +397,7 @@ async def get_color_strip_source(
)
async def update_color_strip_source(
source_id: str,
data: ColorStripSourceUpdate,
data: Annotated[ColorStripSourceUpdate, Body(discriminator="source_type")],
_auth: AuthRequired,
store: ColorStripStore = Depends(get_color_strip_store),
manager: ProcessorManager = Depends(get_processor_manager),
@@ -1,8 +1,9 @@
"""Output target routes: CRUD endpoints and batch state/metrics queries."""
import asyncio
from typing import Annotated
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, Body, HTTPException, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
@@ -12,6 +13,9 @@ from wled_controller.api.dependencies import (
get_processor_manager,
)
from wled_controller.api.schemas.output_targets import (
HALightMappingSchema,
HALightOutputTargetResponse,
LedOutputTargetResponse,
OutputTargetCreate,
OutputTargetListResponse,
OutputTargetResponse,
@@ -25,7 +29,6 @@ from wled_controller.storage.ha_light_output_target import (
HALightMapping,
HALightOutputTarget,
)
from wled_controller.api.schemas.output_targets import HALightMappingSchema
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
@@ -35,58 +38,68 @@ logger = get_logger(__name__)
router = APIRouter()
def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse:
"""Convert a WledOutputTarget to LedOutputTargetResponse."""
return LedOutputTargetResponse(
id=target.id,
name=target.name,
device_id=target.device_id,
color_strip_source_id=target.color_strip_source_id,
brightness=target.brightness.to_dict(),
fps=target.fps.to_dict(),
keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval,
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
adaptive_fps=target.adaptive_fps,
protocol=target.protocol,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
def _ha_light_target_to_response(
target: HALightOutputTarget,
) -> HALightOutputTargetResponse:
"""Convert an HALightOutputTarget to HALightOutputTargetResponse."""
return HALightOutputTargetResponse(
id=target.id,
name=target.name,
ha_source_id=target.ha_source_id,
color_strip_source_id=target.color_strip_source_id,
brightness=target.brightness.to_dict(),
ha_light_mappings=[
HALightMappingSchema(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale.to_dict(),
)
for m in target.light_mappings
],
update_rate=target.update_rate.to_dict(),
transition=target.transition.to_dict(),
color_tolerance=target.color_tolerance.to_dict(),
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
def _target_to_response(target) -> OutputTargetResponse:
"""Convert an OutputTarget to OutputTargetResponse."""
"""Convert any OutputTarget to the appropriate typed response."""
if isinstance(target, WledOutputTarget):
return OutputTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
device_id=target.device_id,
color_strip_source_id=target.color_strip_source_id,
brightness=target.brightness.to_dict(),
fps=target.fps.to_dict(),
keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval,
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
adaptive_fps=target.adaptive_fps,
protocol=target.protocol,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
return _led_target_to_response(target)
elif isinstance(target, HALightOutputTarget):
return OutputTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
ha_source_id=target.ha_source_id,
color_strip_source_id=target.color_strip_source_id,
brightness=target.brightness.to_dict(),
ha_light_mappings=[
HALightMappingSchema(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale.to_dict(),
)
for m in target.light_mappings
],
update_rate=target.update_rate.to_dict(),
transition=target.transition.to_dict(),
color_tolerance=target.color_tolerance.to_dict(),
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
return _ha_light_target_to_response(target)
else:
return OutputTargetResponse(
# Fallback for unknown types — use LED response with defaults
return LedOutputTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
description=target.description,
tags=target.tags,
created_at=target.created_at,
@@ -101,7 +114,7 @@ def _target_to_response(target) -> OutputTargetResponse:
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
)
async def create_target(
data: OutputTargetCreate,
data: Annotated[OutputTargetCreate, Body(discriminator="target_type")],
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
device_store: DeviceStore = Depends(get_device_store),
@@ -110,12 +123,14 @@ async def create_target(
"""Create a new output target."""
try:
# Validate device exists if provided
if data.device_id:
device_id = getattr(data, "device_id", "")
if device_id:
try:
device_store.get_device(data.device_id)
device_store.get_device(device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
ha_mappings = (
[
HALightMapping(
@@ -124,9 +139,9 @@ async def create_target(
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
)
for m in data.ha_light_mappings
for m in ha_light_mappings_raw
]
if data.ha_light_mappings
if ha_light_mappings_raw
else None
)
@@ -134,22 +149,22 @@ async def create_target(
target = target_store.create_target(
name=data.name,
target_type=data.target_type,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
brightness=data.brightness,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
device_id=device_id,
color_strip_source_id=getattr(data, "color_strip_source_id", ""),
brightness=getattr(data, "brightness", 1.0),
fps=getattr(data, "fps", 30),
keepalive_interval=getattr(data, "keepalive_interval", 1.0),
state_check_interval=getattr(data, "state_check_interval", 30),
min_brightness_threshold=getattr(data, "min_brightness_threshold", 0),
adaptive_fps=getattr(data, "adaptive_fps", False),
protocol=getattr(data, "protocol", "ddp"),
description=data.description,
tags=data.tags,
ha_source_id=data.ha_source_id,
ha_source_id=getattr(data, "ha_source_id", ""),
ha_light_mappings=ha_mappings,
update_rate=data.update_rate,
transition=data.transition,
color_tolerance=data.color_tolerance,
update_rate=getattr(data, "update_rate", 2.0),
transition=getattr(data, "transition", 0.5),
color_tolerance=getattr(data, "color_tolerance", 5),
)
# Register in processor manager
@@ -223,7 +238,7 @@ async def get_target(
)
async def update_target(
target_id: str,
data: OutputTargetUpdate,
data: Annotated[OutputTargetUpdate, Body(discriminator="target_type")],
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
device_store: DeviceStore = Depends(get_device_store),
@@ -232,15 +247,17 @@ async def update_target(
"""Update a output target."""
try:
# Validate device exists if changing
if data.device_id is not None and data.device_id:
device_id = getattr(data, "device_id", None)
if device_id is not None and device_id:
try:
device_store.get_device(data.device_id)
device_store.get_device(device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
# Build HA light mappings if provided
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
ha_mappings = None
if data.ha_light_mappings is not None:
if ha_light_mappings_raw is not None:
ha_mappings = [
HALightMapping(
entity_id=m.entity_id,
@@ -248,57 +265,68 @@ async def update_target(
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
)
for m in data.ha_light_mappings
for m in ha_light_mappings_raw
]
# Update in store
target = target_store.update_target(
target_id=target_id,
name=data.name,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
brightness=data.brightness,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
device_id=device_id,
color_strip_source_id=getattr(data, "color_strip_source_id", None),
brightness=getattr(data, "brightness", None),
fps=getattr(data, "fps", None),
keepalive_interval=getattr(data, "keepalive_interval", None),
state_check_interval=getattr(data, "state_check_interval", None),
min_brightness_threshold=getattr(data, "min_brightness_threshold", None),
adaptive_fps=getattr(data, "adaptive_fps", None),
protocol=getattr(data, "protocol", None),
description=data.description,
tags=data.tags,
ha_source_id=data.ha_source_id,
ha_source_id=getattr(data, "ha_source_id", None),
ha_light_mappings=ha_mappings,
update_rate=data.update_rate,
transition=data.transition,
color_tolerance=data.color_tolerance,
update_rate=getattr(data, "update_rate", None),
transition=getattr(data, "transition", None),
color_tolerance=getattr(data, "color_tolerance", None),
)
# Sync processor manager (run in thread — css release/acquire can block)
color_strip_source_id = getattr(data, "color_strip_source_id", None)
fps = getattr(data, "fps", None)
keepalive_interval = getattr(data, "keepalive_interval", None)
state_check_interval = getattr(data, "state_check_interval", None)
min_brightness_threshold = getattr(data, "min_brightness_threshold", None)
adaptive_fps = getattr(data, "adaptive_fps", None)
update_rate = getattr(data, "update_rate", None)
transition = getattr(data, "transition", None)
color_tolerance = getattr(data, "color_tolerance", None)
brightness = getattr(data, "brightness", None)
try:
await asyncio.to_thread(
target.sync_with_manager,
manager,
settings_changed=(
data.fps is not None
or data.keepalive_interval is not None
or data.state_check_interval is not None
or data.min_brightness_threshold is not None
or data.adaptive_fps is not None
or data.update_rate is not None
or data.transition is not None
or data.color_tolerance is not None
or data.ha_light_mappings is not None
or data.brightness is not None
fps is not None
or keepalive_interval is not None
or state_check_interval is not None
or min_brightness_threshold is not None
or adaptive_fps is not None
or update_rate is not None
or transition is not None
or color_tolerance is not None
or ha_light_mappings_raw is not None
or brightness is not None
),
css_changed=data.color_strip_source_id is not None,
brightness_changed=data.brightness is not None,
css_changed=color_strip_source_id is not None,
brightness_changed=brightness is not None,
)
except ValueError as e:
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
pass
# Device change requires async stop -> swap -> start cycle
if data.device_id is not None:
if device_id is not None:
try:
await manager.update_target_device(target_id, target.device_id)
except ValueError as e:
@@ -2,10 +2,11 @@
import asyncio
import time
from typing import Annotated
import httpx
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Body, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
from wled_controller.api.auth import AuthRequired
@@ -29,6 +30,10 @@ from wled_controller.api.schemas.picture_sources import (
PictureSourceResponse,
PictureSourceTestRequest,
PictureSourceUpdate,
ProcessedPictureSourceResponse,
RawPictureSourceResponse,
StaticImagePictureSourceResponse,
VideoPictureSourceResponse,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
@@ -36,7 +41,12 @@ from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource, VideoCaptureSource
from wled_controller.storage.picture_source import (
ProcessedPictureSource,
ScreenCapturePictureSource,
StaticImagePictureSource,
VideoCaptureSource,
)
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
@@ -45,34 +55,67 @@ logger = get_logger(__name__)
router = APIRouter()
def _stream_to_response(s) -> PictureSourceResponse:
"""Convert a PictureSource to its API response."""
return PictureSourceResponse(
_RESPONSE_MAP = {
ScreenCapturePictureSource: lambda s: RawPictureSourceResponse(
id=s.id,
name=s.name,
stream_type=s.stream_type,
display_index=getattr(s, "display_index", None),
capture_template_id=getattr(s, "capture_template_id", None),
target_fps=getattr(s, "target_fps", None),
source_stream_id=getattr(s, "source_stream_id", None),
postprocessing_template_id=getattr(s, "postprocessing_template_id", None),
image_asset_id=getattr(s, "image_asset_id", None),
created_at=s.created_at,
updated_at=s.updated_at,
description=s.description,
tags=s.tags,
# Video fields
video_asset_id=getattr(s, "video_asset_id", None),
loop=getattr(s, "loop", None),
playback_speed=getattr(s, "playback_speed", None),
start_time=getattr(s, "start_time", None),
end_time=getattr(s, "end_time", None),
resolution_limit=getattr(s, "resolution_limit", None),
clock_id=getattr(s, "clock_id", None),
)
created_at=s.created_at,
updated_at=s.updated_at,
display_index=s.display_index,
capture_template_id=s.capture_template_id,
target_fps=s.target_fps,
),
ProcessedPictureSource: lambda s: ProcessedPictureSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
source_stream_id=s.source_stream_id,
postprocessing_template_id=s.postprocessing_template_id,
),
StaticImagePictureSource: lambda s: StaticImagePictureSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
image_asset_id=s.image_asset_id,
),
VideoCaptureSource: lambda s: VideoPictureSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
video_asset_id=s.video_asset_id,
loop=s.loop,
playback_speed=s.playback_speed,
start_time=s.start_time,
end_time=s.end_time,
resolution_limit=s.resolution_limit,
clock_id=s.clock_id,
target_fps=s.target_fps,
),
}
@router.get("/api/v1/picture-sources", response_model=PictureSourceListResponse, tags=["Picture Sources"])
def _stream_to_response(s) -> PictureSourceResponse:
"""Convert a PictureSource storage model to the matching response schema."""
builder = _RESPONSE_MAP.get(type(s))
if builder is None:
raise ValueError(f"Unknown picture source type: {type(s).__name__}")
return builder(s)
@router.get(
"/api/v1/picture-sources", response_model=PictureSourceListResponse, tags=["Picture Sources"]
)
async def list_picture_sources(
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
@@ -87,7 +130,11 @@ async def list_picture_sources(
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/picture-sources/validate-image", response_model=ImageValidateResponse, tags=["Picture Sources"])
@router.post(
"/api/v1/picture-sources/validate-image",
response_model=ImageValidateResponse,
tags=["Picture Sources"],
)
async def validate_image(
data: ImageValidateRequest,
_auth: AuthRequired,
@@ -120,6 +167,7 @@ async def validate_image(
load_image_file,
thumbnail as make_thumbnail,
)
if isinstance(src, bytes):
image = load_image_bytes(src)
else:
@@ -131,12 +179,12 @@ async def validate_image(
width, height, preview = await asyncio.to_thread(_process_image, img_bytes)
return ImageValidateResponse(
valid=True, width=width, height=height, preview=preview
)
return ImageValidateResponse(valid=True, width=width, height=height, preview=preview)
except httpx.HTTPStatusError as e:
return ImageValidateResponse(valid=False, error=f"HTTP {e.response.status_code}: {e.response.reason_phrase}")
return ImageValidateResponse(
valid=False, error=f"HTTP {e.response.status_code}: {e.response.reason_phrase}"
)
except httpx.RequestError as e:
return ImageValidateResponse(valid=False, error=f"Request failed: {e}")
except Exception as e:
@@ -166,7 +214,12 @@ async def get_full_image(
img_bytes = path
def _encode_full(src):
from wled_controller.utils.image_codec import encode_jpeg, load_image_bytes, load_image_file
from wled_controller.utils.image_codec import (
encode_jpeg,
load_image_bytes,
load_image_file,
)
if isinstance(src, bytes):
image = load_image_bytes(src)
else:
@@ -182,9 +235,14 @@ async def get_full_image(
raise HTTPException(status_code=400, detail=str(e))
@router.post("/api/v1/picture-sources", response_model=PictureSourceResponse, tags=["Picture Sources"], status_code=201)
@router.post(
"/api/v1/picture-sources",
response_model=PictureSourceResponse,
tags=["Picture Sources"],
status_code=201,
)
async def create_picture_source(
data: PictureSourceCreate,
data: Annotated[PictureSourceCreate, Body(discriminator="stream_type")],
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_template_store),
@@ -211,25 +269,13 @@ async def create_picture_source(
detail=f"Postprocessing template not found: {data.postprocessing_template_id}",
)
fields = data.model_dump(exclude={"stream_type", "name", "description", "tags"})
stream = store.create_stream(
name=data.name,
stream_type=data.stream_type,
display_index=data.display_index,
capture_template_id=data.capture_template_id,
target_fps=data.target_fps,
source_stream_id=data.source_stream_id,
postprocessing_template_id=data.postprocessing_template_id,
image_asset_id=data.image_asset_id,
description=data.description,
tags=data.tags,
# Video fields
video_asset_id=data.video_asset_id,
loop=data.loop,
playback_speed=data.playback_speed,
start_time=data.start_time,
end_time=data.end_time,
resolution_limit=data.resolution_limit,
clock_id=data.clock_id,
**fields,
)
fire_entity_event("picture_source", "created", stream.id)
return _stream_to_response(stream)
@@ -245,7 +291,11 @@ async def create_picture_source(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"])
@router.get(
"/api/v1/picture-sources/{stream_id}",
response_model=PictureSourceResponse,
tags=["Picture Sources"],
)
async def get_picture_source(
stream_id: str,
_auth: AuthRequired,
@@ -259,35 +309,21 @@ async def get_picture_source(
raise HTTPException(status_code=404, detail=f"Picture source {stream_id} not found")
@router.put("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"])
@router.put(
"/api/v1/picture-sources/{stream_id}",
response_model=PictureSourceResponse,
tags=["Picture Sources"],
)
async def update_picture_source(
stream_id: str,
data: PictureSourceUpdate,
data: Annotated[PictureSourceUpdate, Body(discriminator="stream_type")],
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
):
"""Update a picture source."""
try:
stream = store.update_stream(
stream_id=stream_id,
name=data.name,
display_index=data.display_index,
capture_template_id=data.capture_template_id,
target_fps=data.target_fps,
source_stream_id=data.source_stream_id,
postprocessing_template_id=data.postprocessing_template_id,
image_asset_id=data.image_asset_id,
description=data.description,
tags=data.tags,
# Video fields
video_asset_id=data.video_asset_id,
loop=data.loop,
playback_speed=data.playback_speed,
start_time=data.start_time,
end_time=data.end_time,
resolution_limit=data.resolution_limit,
clock_id=data.clock_id,
)
fields = data.model_dump(exclude={"stream_type"}, exclude_none=True)
stream = store.update_stream(stream_id=stream_id, **fields)
fire_entity_event("picture_source", "updated", stream_id)
return _stream_to_response(stream)
except EntityNotFoundError as e:
@@ -316,7 +352,7 @@ async def delete_picture_source(
raise HTTPException(
status_code=409,
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. "
"Please reassign those targets before deleting.",
"Please reassign those targets before deleting.",
)
store.delete_stream(stream_id)
fire_entity_event("picture_source", "deleted", stream_id)
@@ -350,8 +386,11 @@ async def get_video_thumbnail(
# Resolve video asset to file path
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
asset_store = _get_asset_store()
video_path = asset_store.get_file_path(source.video_asset_id) if source.video_asset_id else None
video_path = (
asset_store.get_file_path(source.video_asset_id) if source.video_asset_id else None
)
if not video_path:
raise HTTPException(status_code=400, detail="Video asset not found or missing file")
@@ -375,7 +414,11 @@ async def get_video_thumbnail(
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"])
@router.post(
"/api/v1/picture-sources/{stream_id}/test",
response_model=TemplateTestResponse,
tags=["Picture Sources"],
)
async def test_picture_source(
stream_id: str,
test_request: PictureSourceTestRequest,
@@ -410,7 +453,11 @@ async def test_picture_source(
from wled_controller.utils.image_codec import load_image_file
asset_store = _get_asset_store()
image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
image_path = (
asset_store.get_file_path(raw_stream.image_asset_id)
if raw_stream.image_asset_id
else None
)
if not image_path:
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
@@ -460,7 +507,9 @@ async def test_picture_source(
frame_count = 1
last_frame = screen_capture
else:
logger.info(f"Starting {test_request.capture_duration}s stream test for {stream_id}")
logger.info(
f"Starting {test_request.capture_duration}s stream test for {stream_id}"
)
end_time = start_time + test_request.capture_duration
while time.perf_counter() < end_time:
capture_start = time.perf_counter()
@@ -482,7 +531,10 @@ async def test_picture_source(
image = last_frame.image
# Create thumbnail + encode (CPU-bound — run in thread)
from wled_controller.utils.image_codec import encode_jpeg_data_uri, thumbnail as make_thumbnail
from wled_controller.utils.image_codec import (
encode_jpeg_data_uri,
thumbnail as make_thumbnail,
)
pp_template_ids = chain["postprocessing_template_ids"]
flat_filters = None
@@ -491,13 +543,16 @@ async def test_picture_source(
pp_template = pp_store.get_template(pp_template_ids[0])
flat_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
except ValueError:
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
logger.warning(
f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview"
)
def _create_thumbnails_and_encode(img, filters):
thumb = make_thumbnail(img, 640)
if filters:
pool = ImagePool()
def apply_filters(arr):
for fi in filters:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
@@ -505,6 +560,7 @@ async def test_picture_source(
if result is not None:
arr = result
return arr
thumb = apply_filters(thumb)
img = apply_filters(img)
@@ -513,8 +569,8 @@ async def test_picture_source(
th, tw = thumb.shape[:2]
return tw, th, thumb_uri, full_uri
thumbnail_width, thumbnail_height, thumbnail_data_uri, full_data_uri = await asyncio.to_thread(
_create_thumbnails_and_encode, image, flat_filters
thumbnail_width, thumbnail_height, thumbnail_data_uri, full_data_uri = (
await asyncio.to_thread(_create_thumbnails_and_encode, image, flat_filters)
)
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
@@ -610,7 +666,11 @@ async def test_picture_source_ws(
from wled_controller.api.dependencies import get_asset_store as _get_asset_store2
asset_store = _get_asset_store2()
video_path = asset_store.get_file_path(raw_stream.video_asset_id) if raw_stream.video_asset_id else None
video_path = (
asset_store.get_file_path(raw_stream.video_asset_id)
if raw_stream.video_asset_id
else None
)
if not video_path:
await websocket.close(code=4004, reason="Video asset not found or missing file")
return
@@ -631,6 +691,7 @@ async def test_picture_source_ws(
def _encode_video_frame(image, pw):
"""Encode numpy RGB image as JPEG base64 data URI."""
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
if pw:
image = resize_down(image, pw)
h, w = image.shape[:2]
@@ -639,6 +700,7 @@ async def test_picture_source_ws(
try:
await asyncio.get_event_loop().run_in_executor(None, video_stream.start)
import time as _time
fps = min(raw_stream.target_fps or 30, 30)
frame_time = 1.0 / fps
end_at = _time.monotonic() + duration
@@ -650,30 +712,42 @@ async def test_picture_source_ws(
last_frame = frame
frame_count += 1
thumb, w, h = await asyncio.get_event_loop().run_in_executor(
None, _encode_video_frame, frame.image, preview_width or None,
None,
_encode_video_frame,
frame.image,
preview_width or None,
)
elapsed = duration - (end_at - _time.monotonic())
await websocket.send_json({
"type": "frame",
"thumbnail": thumb,
"width": w, "height": h,
"elapsed": round(elapsed, 1),
"frame_count": frame_count,
})
await websocket.send_json(
{
"type": "frame",
"thumbnail": thumb,
"width": w,
"height": h,
"elapsed": round(elapsed, 1),
"frame_count": frame_count,
}
)
await asyncio.sleep(frame_time)
# Send final result
if last_frame is not None:
full_img, fw, fh = await asyncio.get_event_loop().run_in_executor(
None, _encode_video_frame, last_frame.image, None,
None,
_encode_video_frame,
last_frame.image,
None,
)
await websocket.send_json(
{
"type": "result",
"full_image": full_img,
"width": fw,
"height": fh,
"total_frames": frame_count,
"duration": duration,
"avg_fps": round(frame_count / max(duration, 0.001), 1),
}
)
await websocket.send_json({
"type": "result",
"full_image": full_img,
"width": fw, "height": fh,
"total_frames": frame_count,
"duration": duration,
"avg_fps": round(frame_count / max(duration, 0.001), 1),
})
except WebSocketDisconnect:
logger.debug("Video source test WebSocket disconnected for %s", stream_id)
pass
@@ -701,7 +775,9 @@ async def test_picture_source_ws(
return
if capture_template.engine_type not in EngineRegistry.get_available_engines():
await websocket.close(code=4003, reason=f"Engine '{capture_template.engine_type}' not available")
await websocket.close(
code=4003, reason=f"Engine '{capture_template.engine_type}' not available"
)
return
# Resolve postprocessing filters (if any)
@@ -731,7 +807,9 @@ async def test_picture_source_ws(
try:
await stream_capture_test(
websocket, engine_factory, duration,
websocket,
engine_factory,
duration,
pp_filters=pp_filters,
preview_width=preview_width or None,
)
@@ -1,9 +1,9 @@
"""Value source routes: CRUD for value sources."""
import asyncio
from typing import Optional
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
@@ -13,12 +13,37 @@ from wled_controller.api.dependencies import (
get_value_source_store,
)
from wled_controller.api.schemas.value_sources import (
AdaptiveSceneValueSourceResponse,
AdaptiveTimeColorValueSourceResponse,
AdaptiveTimeValueSourceResponse,
AnimatedColorValueSourceResponse,
AnimatedValueSourceResponse,
AudioValueSourceResponse,
CSSExtractValueSourceResponse,
DaylightValueSourceResponse,
GradientMapValueSourceResponse,
HAEntityValueSourceResponse,
StaticColorValueSourceResponse,
StaticValueSourceResponse,
ValueSourceCreate,
ValueSourceListResponse,
ValueSourceResponse,
ValueSourceUpdate,
)
from wled_controller.storage.value_source import ValueSource
from wled_controller.storage.value_source import (
AdaptiveTimeColorValueSource,
AdaptiveValueSource,
AnimatedColorValueSource,
AnimatedValueSource,
AudioValueSource,
CSSExtractValueSource,
DaylightValueSource,
GradientMapValueSource,
HAEntityValueSource,
StaticColorValueSource,
StaticValueSource,
ValueSource,
)
from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.core.processing.processor_manager import ProcessorManager
@@ -29,40 +54,178 @@ logger = get_logger(__name__)
router = APIRouter()
# Maps storage class to the response builder for that type.
_RESPONSE_MAP = {
StaticValueSource: lambda s: StaticValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
value=s.value,
),
AnimatedValueSource: lambda s: AnimatedValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
waveform=s.waveform,
speed=s.speed,
min_value=s.min_value,
max_value=s.max_value,
),
AudioValueSource: lambda s: AudioValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
audio_source_id=s.audio_source_id,
mode=s.mode,
sensitivity=s.sensitivity,
smoothing=s.smoothing,
min_value=s.min_value,
max_value=s.max_value,
auto_gain=s.auto_gain,
),
DaylightValueSource: lambda s: DaylightValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
speed=s.speed,
use_real_time=s.use_real_time,
latitude=s.latitude,
min_value=s.min_value,
max_value=s.max_value,
),
StaticColorValueSource: lambda s: StaticColorValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
color=list(s.color),
),
AnimatedColorValueSource: lambda s: AnimatedColorValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
colors=[list(c) for c in s.colors],
speed=s.speed,
easing=s.easing,
),
AdaptiveTimeColorValueSource: lambda s: AdaptiveTimeColorValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
schedule=s.schedule,
),
HAEntityValueSource: lambda s: HAEntityValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
ha_source_id=s.ha_source_id,
entity_id=s.entity_id,
attribute=s.attribute,
min_ha_value=s.min_ha_value,
max_ha_value=s.max_ha_value,
smoothing=s.smoothing,
),
GradientMapValueSource: lambda s: GradientMapValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
value_source_id=s.value_source_id,
gradient_id=s.gradient_id,
easing=s.easing,
),
CSSExtractValueSource: lambda s: CSSExtractValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
color_strip_source_id=s.color_strip_source_id,
led_start=s.led_start,
led_end=s.led_end,
),
}
def _to_response(source: ValueSource) -> ValueSourceResponse:
"""Convert a ValueSource to a ValueSourceResponse."""
d = source.to_dict()
return ValueSourceResponse(
id=d["id"],
name=d["name"],
source_type=d["source_type"],
value=d.get("value"),
waveform=d.get("waveform"),
speed=d.get("speed"),
min_value=d.get("min_value"),
max_value=d.get("max_value"),
audio_source_id=d.get("audio_source_id"),
mode=d.get("mode"),
sensitivity=d.get("sensitivity"),
smoothing=d.get("smoothing"),
auto_gain=d.get("auto_gain"),
schedule=d.get("schedule"),
picture_source_id=d.get("picture_source_id"),
scene_behavior=d.get("scene_behavior"),
use_real_time=d.get("use_real_time"),
latitude=d.get("latitude"),
description=d.get("description"),
tags=d.get("tags", []),
created_at=source.created_at,
updated_at=source.updated_at,
)
"""Convert a ValueSource dataclass to the matching response schema."""
# AdaptiveValueSource covers both adaptive_time and adaptive_scene
if isinstance(source, AdaptiveValueSource):
if source.source_type == "adaptive_scene":
return AdaptiveSceneValueSourceResponse(
id=source.id,
name=source.name,
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
picture_source_id=source.picture_source_id,
scene_behavior=source.scene_behavior,
sensitivity=source.sensitivity,
smoothing=source.smoothing,
min_value=source.min_value,
max_value=source.max_value,
)
return AdaptiveTimeValueSourceResponse(
id=source.id,
name=source.name,
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
schedule=source.schedule,
min_value=source.min_value,
max_value=source.max_value,
)
builder = _RESPONSE_MAP.get(type(source))
if builder is None:
# Fallback for unknown types — return as static
return StaticValueSourceResponse(
id=source.id,
name=source.name,
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
value=getattr(source, "value", 1.0),
)
return builder(source)
@router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"])
async def list_value_sources(
_auth: AuthRequired,
source_type: Optional[str] = Query(None, description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene"),
source_type: Optional[str] = Query(
None,
description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene",
),
store: ValueSourceStore = Depends(get_value_source_store),
):
"""List all value sources, optionally filtered by type."""
@@ -75,34 +238,27 @@ async def list_value_sources(
)
@router.post("/api/v1/value-sources", response_model=ValueSourceResponse, status_code=201, tags=["Value Sources"])
@router.post(
"/api/v1/value-sources",
response_model=ValueSourceResponse,
status_code=201,
tags=["Value Sources"],
)
async def create_value_source(
data: ValueSourceCreate,
data: Annotated[ValueSourceCreate, Body(discriminator="source_type")],
_auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store),
):
"""Create a new value source."""
try:
# Extract all fields from the discriminated union body
fields = data.model_dump(exclude={"source_type", "name", "description", "tags"})
source = store.create_source(
name=data.name,
source_type=data.source_type,
value=data.value,
waveform=data.waveform,
speed=data.speed,
min_value=data.min_value,
max_value=data.max_value,
audio_source_id=data.audio_source_id,
mode=data.mode,
sensitivity=data.sensitivity,
smoothing=data.smoothing,
description=data.description,
schedule=data.schedule,
picture_source_id=data.picture_source_id,
scene_behavior=data.scene_behavior,
auto_gain=data.auto_gain,
use_real_time=data.use_real_time,
latitude=data.latitude,
tags=data.tags,
**fields,
)
fire_entity_event("value_source", "created", source.id)
return _to_response(source)
@@ -113,7 +269,9 @@ async def create_value_source(
raise HTTPException(status_code=400, detail=str(e))
@router.get("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
@router.get(
"/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"]
)
async def get_value_source(
source_id: str,
_auth: AuthRequired,
@@ -127,37 +285,21 @@ async def get_value_source(
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
@router.put(
"/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"]
)
async def update_value_source(
source_id: str,
data: ValueSourceUpdate,
data: Annotated[ValueSourceUpdate, Body(discriminator="source_type")],
_auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store),
pm: ProcessorManager = Depends(get_processor_manager),
):
"""Update an existing value source."""
try:
source = store.update_source(
source_id=source_id,
name=data.name,
value=data.value,
waveform=data.waveform,
speed=data.speed,
min_value=data.min_value,
max_value=data.max_value,
audio_source_id=data.audio_source_id,
mode=data.mode,
sensitivity=data.sensitivity,
smoothing=data.smoothing,
description=data.description,
schedule=data.schedule,
picture_source_id=data.picture_source_id,
scene_behavior=data.scene_behavior,
auto_gain=data.auto_gain,
use_real_time=data.use_real_time,
latitude=data.latitude,
tags=data.tags,
)
# Extract all fields, excluding None values and the discriminator
fields = data.model_dump(exclude={"source_type"}, exclude_none=True)
source = store.update_source(source_id=source_id, **fields)
# Hot-reload running value streams
pm.update_value_source(source_id)
fire_entity_event("value_source", "updated", source_id)
@@ -180,12 +322,11 @@ async def delete_value_source(
try:
# Check if any targets reference this value source
from wled_controller.storage.wled_output_target import WledOutputTarget
for target in target_store.get_all_targets():
if isinstance(target, WledOutputTarget):
if getattr(target, "brightness_value_source_id", "") == source_id:
raise ValueError(
f"Cannot delete: referenced by target '{target.name}'"
)
raise ValueError(f"Cannot delete: referenced by target '{target.name}'")
store.delete_source(source_id)
fire_entity_event("value_source", "deleted", source_id)
@@ -211,6 +352,7 @@ async def test_value_source_ws(
and streams {value: float} JSON to the client.
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -1,67 +1,149 @@
"""Audio source schemas (CRUD)."""
"""Audio source schemas — discriminated unions per source type."""
from datetime import datetime
from typing import List, Literal, Optional
from typing import Annotated, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, Discriminator, Field, Tag
# =====================================================================
# Response schemas (per-type, discriminated union)
# =====================================================================
class AudioSourceCreate(BaseModel):
"""Request to create an audio source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["multichannel", "mono", "band_extract"] = Field(description="Source type")
# multichannel fields
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
# mono fields
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
# band_extract fields
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)", ge=20, le=20000)
freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)", ge=20, le=20000)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class AudioSourceUpdate(BaseModel):
"""Request to update an audio source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)", ge=20, le=20000)
freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)", ge=20, le=20000)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class AudioSourceResponse(BaseModel):
"""Audio source response."""
class _AudioSourceResponseBase(BaseModel):
"""Shared fields for all audio source responses."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
source_type: str = Field(description="Source type: multichannel, mono, or band_extract")
device_index: Optional[int] = Field(None, description="Audio device index")
is_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)")
freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class MultichannelAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["multichannel"] = "multichannel"
device_index: int = Field(description="Audio device index (-1 = default)")
is_loopback: bool = Field(description="WASAPI loopback mode")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
class MonoAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["mono"] = "mono"
audio_source_id: str = Field(description="Parent audio source ID")
channel: str = Field(description="Channel: mono|left|right")
class BandExtractAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["band_extract"] = "band_extract"
audio_source_id: str = Field(description="Parent audio source ID")
band: str = Field(description="Band preset: bass|mid|treble|custom")
freq_low: float = Field(description="Low frequency bound (Hz)")
freq_high: float = Field(description="High frequency bound (Hz)")
AudioSourceResponse = Annotated[
Union[
Annotated[MultichannelAudioSourceResponse, Tag("multichannel")],
Annotated[MonoAudioSourceResponse, Tag("mono")],
Annotated[BandExtractAudioSourceResponse, Tag("band_extract")],
],
Discriminator("source_type"),
]
# =====================================================================
# Create schemas (per-type, discriminated union)
# =====================================================================
class _AudioSourceCreateBase(BaseModel):
"""Shared fields for all audio source create requests."""
name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class MultichannelAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["multichannel"] = "multichannel"
device_index: int = Field(-1, description="Audio device index (-1 = default)")
is_loopback: bool = Field(True, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
class MonoAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["mono"] = "mono"
audio_source_id: str = Field("", description="Parent audio source ID")
channel: str = Field("mono", description="Channel: mono|left|right")
class BandExtractAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["band_extract"] = "band_extract"
audio_source_id: str = Field("", description="Parent audio source ID")
band: str = Field("bass", description="Band preset: bass|mid|treble|custom")
freq_low: float = Field(20.0, description="Low frequency bound (Hz)", ge=20, le=20000)
freq_high: float = Field(250.0, description="High frequency bound (Hz)", ge=20, le=20000)
AudioSourceCreate = Annotated[
Union[
Annotated[MultichannelAudioSourceCreate, Tag("multichannel")],
Annotated[MonoAudioSourceCreate, Tag("mono")],
Annotated[BandExtractAudioSourceCreate, Tag("band_extract")],
],
Discriminator("source_type"),
]
# =====================================================================
# Update schemas (per-type, discriminated union)
# =====================================================================
class _AudioSourceUpdateBase(BaseModel):
"""Shared fields for all audio source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class MultichannelAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["multichannel"] = "multichannel"
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
class MonoAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["mono"] = "mono"
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
class BandExtractAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["band_extract"] = "band_extract"
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)", ge=20, le=20000)
freq_high: Optional[float] = Field(
None, description="High frequency bound (Hz)", ge=20, le=20000
)
AudioSourceUpdate = Annotated[
Union[
Annotated[MultichannelAudioSourceUpdate, Tag("multichannel")],
Annotated[MonoAudioSourceUpdate, Tag("mono")],
Annotated[BandExtractAudioSourceUpdate, Tag("band_extract")],
],
Discriminator("source_type"),
]
# =====================================================================
# List response
# =====================================================================
class AudioSourceListResponse(BaseModel):
"""List of audio sources."""
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,9 @@
"""Output target schemas (CRUD, processing state, metrics)."""
"""Output target schemas — discriminated unions per target type."""
from datetime import datetime
from typing import Any, Dict, Optional, List, Union
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, Discriminator, Field, Tag
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
@@ -43,12 +43,86 @@ class HALightMappingSchema(BaseModel):
)
class OutputTargetCreate(BaseModel):
"""Request to create an output target."""
# =====================================================================
# Response schemas (per-type, discriminated union)
# =====================================================================
class _OutputTargetResponseBase(BaseModel):
"""Shared fields for all output target responses."""
id: str = Field(description="Target ID")
name: str = Field(description="Target name")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class LedOutputTargetResponse(_OutputTargetResponseBase):
target_type: Literal["led"] = "led"
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable)")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive"
)
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
class HALightOutputTargetResponse(_OutputTargetResponseBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: str = Field(default="", description="Home Assistant source ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
None, description="Service call rate Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
None, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
OutputTargetResponse = Annotated[
Union[
Annotated[LedOutputTargetResponse, Tag("led")],
Annotated[HALightOutputTargetResponse, Tag("ha_light")],
],
Discriminator("target_type"),
]
# =====================================================================
# Create schemas (per-type, discriminated union)
# =====================================================================
class _OutputTargetCreateBase(BaseModel):
"""Shared fields for all output target create requests."""
name: str = Field(description="Target name", min_length=1, max_length=100)
target_type: str = Field(default="led", description="Target type (led, ha_light)")
# LED target fields
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class LedOutputTargetCreate(_OutputTargetCreateBase):
target_type: Literal["led"] = "led"
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(
@@ -71,7 +145,7 @@ class OutputTargetCreate(BaseModel):
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this off",
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive"
@@ -81,32 +155,56 @@ class OutputTargetCreate(BaseModel):
pattern="^(ddp|http)$",
description="Send protocol: ddp (UDP) or http (JSON API)",
)
# HA light target fields
ha_source_id: str = Field(
default="", description="Home Assistant source ID (for ha_light targets)"
class HALightOutputTargetCreate(_OutputTargetCreateBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: str = Field(default="", description="Home Assistant source ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (for ha_light targets)"
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
default=2.0, description="Service call rate in Hz (bindable, for ha_light targets)"
default=2.0, description="Service call rate in Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
default=0.5, description="HA transition seconds (bindable, for ha_light targets)"
default=0.5, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
default=5,
description="RGB delta tolerance (bindable, for ha_light targets)",
default=5, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class OutputTargetUpdate(BaseModel):
"""Request to update an output target."""
OutputTargetCreate = Annotated[
Union[
Annotated[LedOutputTargetCreate, Tag("led")],
Annotated[HALightOutputTargetCreate, Tag("ha_light")],
],
Discriminator("target_type"),
]
# =====================================================================
# Update schemas (per-type, discriminated union)
# =====================================================================
class _OutputTargetUpdateBase(BaseModel):
"""Shared fields for all output target update requests."""
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
# LED target fields
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class LedOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["led"] = "led"
device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
@@ -126,64 +224,41 @@ class OutputTargetUpdate(BaseModel):
protocol: Optional[str] = Field(
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
)
# HA light target fields
ha_source_id: Optional[str] = Field(
None, description="Home Assistant source ID (for ha_light targets)"
)
class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (for ha_light targets)"
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
None, description="Service call rate Hz (bindable, for ha_light targets)"
None, description="Service call rate Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
None, description="HA transition seconds (bindable, for ha_light targets)"
None, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
None, description="RGB delta tolerance (bindable, for ha_light targets)"
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class OutputTargetResponse(BaseModel):
"""Output target response."""
id: str = Field(description="Target ID")
name: str = Field(description="Target name")
target_type: str = Field(description="Target type")
# LED target fields
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable)")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
None, description="Min brightness threshold (bindable, 0=disabled)"
)
adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive"
)
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
# HA light target fields
ha_source_id: str = Field(default="", description="Home Assistant source ID (ha_light)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (ha_light)"
)
update_rate: Optional[BindableFloatInput] = Field(
None, description="Service call rate Hz (bindable, ha_light)"
)
transition: Optional[BindableFloatInput] = Field(
None, description="HA transition seconds (bindable, ha_light)"
)
color_tolerance: Optional[int] = Field(None, description="RGB delta tolerance (ha_light)")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
OutputTargetUpdate = Annotated[
Union[
Annotated[LedOutputTargetUpdate, Tag("led")],
Annotated[HALightOutputTargetUpdate, Tag("ha_light")],
],
Discriminator("target_type"),
]
# =====================================================================
# List response & utility schemas
# =====================================================================
class OutputTargetListResponse(BaseModel):
@@ -1,80 +1,183 @@
"""Picture source schemas."""
"""Picture source schemas — discriminated unions per stream type."""
from datetime import datetime
from typing import List, Literal, Optional
from typing import Annotated, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, Discriminator, Field, Tag
# =====================================================================
# Response schemas (per-type, discriminated union)
# =====================================================================
class PictureSourceCreate(BaseModel):
"""Request to create a picture source."""
name: str = Field(description="Stream name", min_length=1, max_length=100)
stream_type: Literal["raw", "processed", "static_image", "video"] = Field(description="Stream type")
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
image_asset_id: Optional[str] = Field(None, description="Image asset ID (static_image streams)")
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
# Video fields
video_asset_id: Optional[str] = Field(None, description="Video asset ID (video streams)")
loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(None, description="Max width in pixels for decode downscale", ge=64, le=7680)
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
class PictureSourceUpdate(BaseModel):
"""Request to update a picture source."""
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
image_asset_id: Optional[str] = Field(None, description="Image asset ID (static_image streams)")
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: Optional[List[str]] = None
# Video fields
video_asset_id: Optional[str] = Field(None, description="Video asset ID (video streams)")
loop: Optional[bool] = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier", ge=0.1, le=10.0)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(None, description="Max width in pixels for decode downscale", ge=64, le=7680)
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
class PictureSourceResponse(BaseModel):
"""Picture source information response."""
class _PictureSourceResponseBase(BaseModel):
"""Shared fields for all picture source responses."""
id: str = Field(description="Stream ID")
name: str = Field(description="Stream name")
stream_type: str = Field(description="Stream type (raw, processed, static_image, or video)")
display_index: Optional[int] = Field(None, description="Display index")
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
target_fps: Optional[int] = Field(None, description="Target FPS")
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID")
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
description: Optional[str] = Field(None, description="Stream description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Stream description")
# Video fields
class RawPictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["raw"] = "raw"
display_index: int = Field(description="Display index")
capture_template_id: str = Field(description="Capture template ID")
target_fps: int = Field(description="Target FPS")
class ProcessedPictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["processed"] = "processed"
source_stream_id: str = Field(description="Source stream ID")
postprocessing_template_id: str = Field(description="Postprocessing template ID")
class StaticImagePictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["static_image"] = "static_image"
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
class VideoPictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["video"] = "video"
video_asset_id: Optional[str] = Field(None, description="Video asset ID")
loop: Optional[bool] = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier")
loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, description="Playback speed multiplier")
start_time: Optional[float] = Field(None, description="Trim start time in seconds")
end_time: Optional[float] = Field(None, description="Trim end time in seconds")
resolution_limit: Optional[int] = Field(None, description="Max width for decode")
clock_id: Optional[str] = Field(None, description="Sync clock ID")
target_fps: int = Field(30, description="Target FPS")
PictureSourceResponse = Annotated[
Union[
Annotated[RawPictureSourceResponse, Tag("raw")],
Annotated[ProcessedPictureSourceResponse, Tag("processed")],
Annotated[StaticImagePictureSourceResponse, Tag("static_image")],
Annotated[VideoPictureSourceResponse, Tag("video")],
],
Discriminator("stream_type"),
]
# =====================================================================
# Create schemas (per-type, discriminated union)
# =====================================================================
class _PictureSourceCreateBase(BaseModel):
"""Shared fields for all picture source create requests."""
name: str = Field(description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class RawPictureSourceCreate(_PictureSourceCreateBase):
stream_type: Literal["raw"] = "raw"
display_index: int = Field(description="Display index", ge=0)
capture_template_id: str = Field(description="Capture template ID")
target_fps: int = Field(30, description="Target FPS", ge=1, le=90)
class ProcessedPictureSourceCreate(_PictureSourceCreateBase):
stream_type: Literal["processed"] = "processed"
source_stream_id: str = Field(description="Source stream ID")
postprocessing_template_id: str = Field(description="Postprocessing template ID")
class StaticImagePictureSourceCreate(_PictureSourceCreateBase):
stream_type: Literal["static_image"] = "static_image"
image_asset_id: str = Field(description="Image asset ID")
class VideoPictureSourceCreate(_PictureSourceCreateBase):
stream_type: Literal["video"] = "video"
video_asset_id: str = Field(description="Video asset ID")
loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(
None, description="Max width in pixels for decode downscale", ge=64, le=7680
)
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
target_fps: int = Field(30, description="Target FPS", ge=1, le=90)
PictureSourceCreate = Annotated[
Union[
Annotated[RawPictureSourceCreate, Tag("raw")],
Annotated[ProcessedPictureSourceCreate, Tag("processed")],
Annotated[StaticImagePictureSourceCreate, Tag("static_image")],
Annotated[VideoPictureSourceCreate, Tag("video")],
],
Discriminator("stream_type"),
]
# =====================================================================
# Update schemas (per-type, discriminated union)
# =====================================================================
class _PictureSourceUpdateBase(BaseModel):
"""Shared fields for all picture source update requests."""
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: Optional[List[str]] = None
class RawPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["raw"] = "raw"
display_index: Optional[int] = Field(None, description="Display index", ge=0)
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
class ProcessedPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["processed"] = "processed"
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
postprocessing_template_id: Optional[str] = Field(
None, description="Postprocessing template ID"
)
class StaticImagePictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["static_image"] = "static_image"
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
class VideoPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["video"] = "video"
video_asset_id: Optional[str] = Field(None, description="Video asset ID")
loop: Optional[bool] = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field(
None, description="Playback speed multiplier", ge=0.1, le=10.0
)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(
None, description="Max width in pixels for decode downscale", ge=64, le=7680
)
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
PictureSourceUpdate = Annotated[
Union[
Annotated[RawPictureSourceUpdate, Tag("raw")],
Annotated[ProcessedPictureSourceUpdate, Tag("processed")],
Annotated[StaticImagePictureSourceUpdate, Tag("static_image")],
Annotated[VideoPictureSourceUpdate, Tag("video")],
],
Discriminator("stream_type"),
]
# =====================================================================
# List response
# =====================================================================
class PictureSourceListResponse(BaseModel):
@@ -84,11 +187,23 @@ class PictureSourceListResponse(BaseModel):
count: int = Field(description="Number of streams")
# =====================================================================
# Test / Validation (unchanged)
# =====================================================================
class PictureSourceTestRequest(BaseModel):
"""Request to test a picture source."""
capture_duration: float = Field(default=5.0, ge=0.0, le=30.0, description="Duration to capture in seconds (0 = single frame)")
border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels for preview")
capture_duration: float = Field(
default=5.0,
ge=0.0,
le=30.0,
description="Duration to capture in seconds (0 = single frame)",
)
border_width: int = Field(
default=10, ge=1, le=100, description="Border width in pixels for preview"
)
class ImageValidateRequest(BaseModel):
@@ -1,95 +1,402 @@
"""Value source schemas (CRUD)."""
"""Value source schemas — discriminated unions per source type."""
from datetime import datetime
from typing import List, Literal, Optional
from typing import Annotated, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, Discriminator, Field, Tag
# =====================================================================
# Response schemas (per-type, discriminated union)
# =====================================================================
class ValueSourceCreate(BaseModel):
"""Request to create a value source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["static", "animated", "audio", "adaptive_time", "adaptive_scene", "daylight"] = Field(description="Source type")
# static fields
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
# animated fields
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
# audio fields
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels to full range")
# adaptive fields
schedule: Optional[list] = Field(None, description="Time-of-day schedule: [{time: 'HH:MM', value: 0.0-1.0}]")
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
# daylight fields
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class ValueSourceUpdate(BaseModel):
"""Request to update a value source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
# static fields
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
# animated fields
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
# audio fields
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels to full range")
# adaptive fields
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
# daylight fields
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class ValueSourceResponse(BaseModel):
"""Value source response."""
class _ValueSourceResponseBase(BaseModel):
"""Shared fields for all value source responses."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
source_type: str = Field(description="Source type: static, animated, audio, adaptive_time, or adaptive_scene")
value: Optional[float] = Field(None, description="Static value")
waveform: Optional[str] = Field(None, description="Waveform type")
speed: Optional[float] = Field(None, description="Cycles per minute")
min_value: Optional[float] = Field(None, description="Minimum output")
max_value: Optional[float] = Field(None, description="Maximum output")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode")
sensitivity: Optional[float] = Field(None, description="Gain multiplier")
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels")
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Geographic latitude")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class StaticValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["static"] = "static"
return_type: Literal["float"] = "float"
value: float = Field(description="Constant value (0.0-1.0)")
class AnimatedValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["animated"] = "animated"
return_type: Literal["float"] = "float"
waveform: str = Field(description="Waveform type")
speed: float = Field(description="Cycles per minute")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
class AudioValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["audio"] = "audio"
return_type: Literal["float"] = "float"
audio_source_id: str = Field(description="Mono audio source ID")
mode: str = Field(description="Audio mode: rms|peak|beat")
sensitivity: float = Field(description="Gain multiplier")
smoothing: float = Field(description="Temporal smoothing")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
auto_gain: bool = Field(description="Auto-normalize audio levels")
class AdaptiveTimeValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["adaptive_time"] = "adaptive_time"
return_type: Literal["float"] = "float"
schedule: list = Field(description="Time-of-day schedule")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
class AdaptiveSceneValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["adaptive_scene"] = "adaptive_scene"
return_type: Literal["float"] = "float"
picture_source_id: str = Field(description="Picture source ID")
scene_behavior: str = Field(description="Scene behavior: complement|match")
sensitivity: float = Field(description="Gain multiplier")
smoothing: float = Field(description="Temporal smoothing")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
class DaylightValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["daylight"] = "daylight"
return_type: Literal["float"] = "float"
speed: float = Field(description="Simulation speed multiplier")
use_real_time: bool = Field(description="Use wall-clock time")
latitude: float = Field(description="Geographic latitude")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
class StaticColorValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["static_color"] = "static_color"
return_type: Literal["color"] = "color"
color: List[int] = Field(description="Static RGB color [R,G,B]")
class AnimatedColorValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["animated_color"] = "animated_color"
return_type: Literal["color"] = "color"
colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]")
speed: float = Field(description="Cycles per minute")
easing: str = Field(description="Color easing: linear|step")
class AdaptiveTimeColorValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
return_type: Literal["color"] = "color"
schedule: list = Field(description="Color schedule")
class HAEntityValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["ha_entity"] = "ha_entity"
return_type: Literal["float"] = "float"
ha_source_id: str = Field(description="Home Assistant source ID")
entity_id: str = Field(description="HA entity ID (e.g. sensor.temperature)")
attribute: str = Field("", description="Optional attribute name (empty = use state)")
min_ha_value: float = Field(description="Raw HA value mapped to output 0.0")
max_ha_value: float = Field(description="Raw HA value mapped to output 1.0")
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
class GradientMapValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["gradient_map"] = "gradient_map"
return_type: Literal["color"] = "color"
value_source_id: str = Field(description="Input float value source ID")
gradient_id: str = Field(description="Gradient entity ID")
easing: str = Field(description="Interpolation mode: linear|step")
class CSSExtractValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["css_extract"] = "css_extract"
return_type: Literal["color"] = "color"
color_strip_source_id: str = Field(description="Color strip source ID")
led_start: int = Field(description="Start of LED range (0-based)")
led_end: int = Field(description="End of LED range (-1 = whole strip)")
ValueSourceResponse = Annotated[
Union[
Annotated[StaticValueSourceResponse, Tag("static")],
Annotated[AnimatedValueSourceResponse, Tag("animated")],
Annotated[AudioValueSourceResponse, Tag("audio")],
Annotated[AdaptiveTimeValueSourceResponse, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceResponse, Tag("adaptive_scene")],
Annotated[DaylightValueSourceResponse, Tag("daylight")],
Annotated[StaticColorValueSourceResponse, Tag("static_color")],
Annotated[AnimatedColorValueSourceResponse, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceResponse, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceResponse, Tag("ha_entity")],
Annotated[GradientMapValueSourceResponse, Tag("gradient_map")],
Annotated[CSSExtractValueSourceResponse, Tag("css_extract")],
],
Discriminator("source_type"),
]
# =====================================================================
# Create schemas (per-type, discriminated union)
# =====================================================================
class _ValueSourceCreateBase(BaseModel):
"""Shared fields for all value source create requests."""
name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class StaticValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["static"] = "static"
value: float = Field(1.0, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
class AnimatedValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["animated"] = "animated"
waveform: str = Field("sine", description="Waveform: sine|triangle|square|sawtooth")
speed: float = Field(10.0, description="Cycles per minute", ge=0.1, le=120.0)
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
class AudioValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["audio"] = "audio"
audio_source_id: str = Field("", description="Mono audio source ID")
mode: str = Field("rms", description="Audio mode: rms|peak|beat")
sensitivity: float = Field(1.0, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
smoothing: float = Field(0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
auto_gain: bool = Field(False, description="Auto-normalize audio levels to full range")
class AdaptiveTimeValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["adaptive_time"] = "adaptive_time"
schedule: list = Field(description="Schedule: [{time: 'HH:MM', value: 0.0-1.0}]")
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
class AdaptiveSceneValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["adaptive_scene"] = "adaptive_scene"
picture_source_id: str = Field("", description="Picture source ID for scene mode")
scene_behavior: str = Field("complement", description="Scene behavior: complement|match")
sensitivity: float = Field(1.0, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
smoothing: float = Field(0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
class DaylightValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["daylight"] = "daylight"
speed: float = Field(1.0, description="Simulation speed multiplier", ge=0.1, le=120.0)
use_real_time: bool = Field(False, description="Use wall-clock time instead of simulation")
latitude: float = Field(50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
class StaticColorValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["static_color"] = "static_color"
color: List[int] = Field(
default_factory=lambda: [255, 255, 255],
description="Static RGB color [R,G,B]",
)
class AnimatedColorValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["animated_color"] = "animated_color"
colors: List[List[int]] = Field(
default_factory=lambda: [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
description="Color list [[R,G,B], ...]",
)
speed: float = Field(10.0, description="Cycles per minute", ge=0.1, le=120.0)
easing: str = Field("linear", description="Color easing: linear|step")
class AdaptiveTimeColorValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
schedule: list = Field(description="Schedule: [{time: 'HH:MM', color: [R,G,B]}]")
class HAEntityValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["ha_entity"] = "ha_entity"
ha_source_id: str = Field(description="Home Assistant source ID")
entity_id: str = Field(description="HA entity ID")
attribute: str = Field("", description="Optional attribute name")
min_ha_value: float = Field(0.0, description="Raw HA value mapped to output 0.0")
max_ha_value: float = Field(100.0, description="Raw HA value mapped to output 1.0")
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
class GradientMapValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["gradient_map"] = "gradient_map"
value_source_id: str = Field(description="Input float value source ID")
gradient_id: str = Field("", description="Gradient entity ID")
easing: str = Field("linear", description="Interpolation: linear|step")
class CSSExtractValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["css_extract"] = "css_extract"
color_strip_source_id: str = Field(description="Color strip source ID")
led_start: int = Field(0, description="Start of LED range (0-based)", ge=0)
led_end: int = Field(-1, description="End of LED range (-1 = whole strip)")
ValueSourceCreate = Annotated[
Union[
Annotated[StaticValueSourceCreate, Tag("static")],
Annotated[AnimatedValueSourceCreate, Tag("animated")],
Annotated[AudioValueSourceCreate, Tag("audio")],
Annotated[AdaptiveTimeValueSourceCreate, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceCreate, Tag("adaptive_scene")],
Annotated[DaylightValueSourceCreate, Tag("daylight")],
Annotated[StaticColorValueSourceCreate, Tag("static_color")],
Annotated[AnimatedColorValueSourceCreate, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceCreate, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceCreate, Tag("ha_entity")],
Annotated[GradientMapValueSourceCreate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceCreate, Tag("css_extract")],
],
Discriminator("source_type"),
]
# =====================================================================
# Update schemas (per-type, discriminated union)
# =====================================================================
class _ValueSourceUpdateBase(BaseModel):
"""Shared fields for all value source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class StaticValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["static"] = "static"
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
class AnimatedValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["animated"] = "animated"
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
class AudioValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["audio"] = "audio"
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: Optional[float] = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels")
class AdaptiveTimeValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_time"] = "adaptive_time"
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
class AdaptiveSceneValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_scene"] = "adaptive_scene"
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
sensitivity: Optional[float] = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
class DaylightValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["daylight"] = "daylight"
speed: Optional[float] = Field(None, description="Simulation speed", ge=0.1, le=120.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Geographic latitude", ge=-90.0, le=90.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
class StaticColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["static_color"] = "static_color"
color: Optional[List[int]] = Field(None, description="Static RGB color [R,G,B]")
class AnimatedColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["animated_color"] = "animated_color"
colors: Optional[List[List[int]]] = Field(None, description="Color list [[R,G,B], ...]")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
easing: Optional[str] = Field(None, description="Color easing: linear|step")
class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
schedule: Optional[list] = Field(None, description="Color schedule")
class HAEntityValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["ha_entity"] = "ha_entity"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
entity_id: Optional[str] = Field(None, description="HA entity ID")
attribute: Optional[str] = Field(None, description="Attribute name")
min_ha_value: Optional[float] = Field(None, description="Min HA value")
max_ha_value: Optional[float] = Field(None, description="Max HA value")
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
class GradientMapValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["gradient_map"] = "gradient_map"
value_source_id: Optional[str] = Field(None, description="Input value source ID")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
easing: Optional[str] = Field(None, description="Interpolation mode")
class CSSExtractValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["css_extract"] = "css_extract"
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
led_start: Optional[int] = Field(None, description="LED range start", ge=0)
led_end: Optional[int] = Field(None, description="LED range end")
ValueSourceUpdate = Annotated[
Union[
Annotated[StaticValueSourceUpdate, Tag("static")],
Annotated[AnimatedValueSourceUpdate, Tag("animated")],
Annotated[AudioValueSourceUpdate, Tag("audio")],
Annotated[AdaptiveTimeValueSourceUpdate, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceUpdate, Tag("adaptive_scene")],
Annotated[DaylightValueSourceUpdate, Tag("daylight")],
Annotated[StaticColorValueSourceUpdate, Tag("static_color")],
Annotated[AnimatedColorValueSourceUpdate, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceUpdate, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")],
Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")],
],
Discriminator("source_type"),
]
# =====================================================================
# List response
# =====================================================================
class ValueSourceListResponse(BaseModel):
"""List of value sources."""
@@ -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))
@@ -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(
@@ -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)
@@ -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
@@ -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)
@@ -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
@@ -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)
@@ -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
@@ -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.01.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 "<state>",
)
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)
@@ -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 {
@@ -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 {
@@ -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,
@@ -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;
}
}
@@ -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 = `<svg class="icon icon-xs" viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>`;
const colorHtml = `<div class="bindable-slider-row" id="${id}-color-row">
<input type="color" id="${id}-color" value="${hex}">
<button type="button" class="bindable-toggle" id="${id}-toggle" title="${t('bindable.toggle')}">${toggleSvg}</button>
</div>`;
const vsHtml = `<div class="bindable-vs-row" id="${id}-vs-row" style="display:none">
<select id="${id}-select"></select>
<button type="button" class="bindable-toggle bindable-toggle--active" id="${id}-untoggle" title="${t('bindable.toggle')}">${toggleSvg}</button>
</div>`;
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 = `<option value="">${this._opts.noneLabel || t('bindable.none')}</option>` +
sources.map(vs =>
`<option value="${vs.id}"${vs.id === this._sourceId ? ' selected' : ''}>${vs.name}</option>`
).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 = '';
}
}
@@ -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 },
@@ -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
@@ -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 =>
`<div class="icon-select-cell" data-value="${item.value}" data-search="${(item.label + ' ' + (item.desc || '')).toLowerCase()}">
<span class="icon-select-cell-icon">${item.icon}</span>
<span class="icon-select-cell-label">${item.label}</span>
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
</div>`
).join('');
function buildCells(cellItems: IconSelectItem[]): string {
return cellItems.map(item =>
`<div class="icon-select-cell" data-value="${item.value}" data-search="${(item.label + ' ' + (item.desc || '')).toLowerCase()}">
<span class="icon-select-cell-icon">${item.icon}</span>
<span class="icon-select-cell-label">${item.label}</span>
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
</div>`
).join('');
}
// Build filter tabs HTML
const tabsHtml = filterTabs && filterTabs.length > 0
? `<div class="type-picker-tabs">${filterTabs.map((tab, i) =>
`<button class="type-picker-tab${i === 0 ? ' active' : ''}" data-filter-key="${tab.key}">${tab.label}</button>`
).join('')}</div>`
: '';
// Create overlay
const overlay = document.createElement('div');
@@ -305,26 +324,52 @@ export function showTypePicker({ title, items, onPick }: { title: string; items:
overlay.innerHTML = `
<div class="type-picker-dialog">
<div class="type-picker-title">${title}</div>
${tabsHtml}
${showFilter ? '<input class="type-picker-filter" type="text" placeholder="Filter…" autocomplete="off">' : ''}
<div class="icon-select-grid">${cells}</div>
<div class="icon-select-grid">${buildCells(items)}</div>
</div>`;
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'));
}
@@ -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);
@@ -296,10 +296,12 @@ export const automationsCacheObj = new DataCache<Automation[]>({
});
automationsCacheObj.subscribe(v => { _automationsCache = v; });
export let _cachedColorStripSources: ColorStripSource[] = [];
export const colorStripSourcesCache = new DataCache<ColorStripSource[]>({
endpoint: '/color-strip-sources',
extractData: json => json.sources || [],
});
colorStripSourcesCache.subscribe(v => { _cachedColorStripSources = v; });
export const csptCache = new DataCache<ColorStripProcessingTemplate[]>({
endpoint: '/color-strip-processing-templates',
@@ -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;
@@ -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';
@@ -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<void> {
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<void> {
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) {
@@ -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);
@@ -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`, {
@@ -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) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -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 = [];
@@ -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');
@@ -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<void> {
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<string, () => 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<string, CardPropsRenderer> = {
static: (source, { clockBadge, animBadge }) => {
const hexColor = rgbArrayToHex(source.color!);
const hexColor = rgbArrayToHex(bindableColor(source.color, [255,255,255]));
return `
<span class="stream-card-prop" title="${t('color_strip.static_color')}">
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
@@ -1320,7 +1426,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
`;
},
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<string, CardPropsRenderer> = {
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 `
<span class="stream-card-prop" title="${t('color_strip.notification.effect')}">${ICON_BELL} ${escapeHtml(effectLabel)}</span>
<span class="stream-card-prop" title="${t('color_strip.notification.duration')}">${ICON_TIMER} ${durationVal}ms</span>
<span class="stream-card-prop" title="${t('color_strip.notification.default_color')}">
<span style="display:inline-block;width:14px;height:14px;background:${defColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${defColor.toUpperCase()}
<span style="display:inline-block;width:14px;height:14px;background:${defColorHex};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${defColorHex.toUpperCase()}
</span>
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
`;
@@ -1354,7 +1461,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
`;
},
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 `
<span class="stream-card-prop" title="${t('color_strip.candlelight.color')}">
@@ -1539,17 +1646,17 @@ function _autoGenerateCSSName() {
const _typeHandlers: Record<string, { load: (...args: any[]) => 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<string, { load: (...args: any[]) => 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<string, { load: (...args: any[]) => 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<string, { load: (...args: any[]) => 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<string, { load: (...args: any[]) => 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<string, { load: (...args: any[]) => 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<string, { load: (...args: any[]) => 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<string, { load: (...args: any[]) => 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<string, { load: (...args: any[]) => 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<string, { load: (...args: any[]) => 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<string, { load: (...args: any[]) => 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'];
@@ -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 `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}" onclick="toggleDashboardSection('${sectionKey}')">
<span class="dashboard-section-chevron"${chevronStyle}>&#9654;</span>
${label}
<span class="dashboard-section-count">${count}</span>
return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}">
<span class="dashboard-section-toggle" onclick="toggleDashboardSection('${sectionKey}')">
<span class="dashboard-section-chevron"${chevronStyle}>&#9654;</span>
${label}
<span class="dashboard-section-count">${count}</span>
</span>
${extraHtml}
</div>`;
}
@@ -682,7 +682,7 @@ async function _fetchAllEntities(): Promise<Record<string, any>> {
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),
]);
@@ -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) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
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 = `<option value="">${t('targets.brightness_vs.none')}</option>` +
_cachedValueSources.map((vs: any) =>
`<option value="${vs.id}" ${vs.id === bindableSourceId(editData?.brightness) ? 'selected' : ''}>${escapeHtml(vs.name)}</option>`
).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<void> {
// 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<void> {
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
};
payload.target_type = 'ha_light';
try {
let response;
if (targetId) {
@@ -454,7 +455,6 @@ export async function saveHALightEditor(): Promise<void> {
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<string,
${cssName !== '—' ? `<span class="stream-card-prop${cssLink}" title="${t('targets.color_strip_source')}">${cssSource ? getColorStripIcon(cssSource.source_type) : _icon(P.palette)} ${cssName}</span>` : ''}
<span class="stream-card-prop">${_icon(P.listChecks)} ${mappingCount} light${mappingCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop">${_icon(P.clock)} ${target.update_rate ?? 2.0} Hz</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : (bindableValue(target.brightness, 1.0) < 1.0 ? `<span class="stream-card-prop" title="${t('targets.brightness')}">${ICON_SUN} ${Math.round(bindableValue(target.brightness, 1.0) * 100)}%</span>` : '')}
</div>
${renderTagChips(target.tags || [])}
${target.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(target.description)}</div>` : ''}
@@ -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<void> {
export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null, opts?: { rects?: any[]; pictureSourceId?: string; onSave?: (rects: any[]) => void }): Promise<void> {
_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');
@@ -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));
@@ -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 = `<option value="">${t('targets.brightness_vs.none')}</option>`;
_cachedValueSources.forEach(vs => {
html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${escapeHtml(vs.name)}</option>`;
});
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) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -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<string, any>, colorStripSourceMap: Record<string, any>, valueSourceMap: Record<string, any>) {
export function createTargetCard(target: LedOutputTarget & { state?: any; metrics?: any }, deviceMap: Record<string, any>, colorStripSourceMap: Record<string, any>, valueSourceMap: Record<string, any>) {
const state = target.state || {};
const isProcessing = state.processing || false;
@@ -1015,7 +1006,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${bindableValue(target.fps, 30)}</span>
<span class="stream-card-prop" title="${t('targets.protocol')}">${_protocolBadge(device, target)}</span>
<span class="stream-card-prop${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : (bindableValue(target.brightness, 1.0) < 1.0 ? `<span class="stream-card-prop" title="${t('targets.brightness')}">${ICON_SUN} ${Math.round(bindableValue(target.brightness, 1.0) * 100)}%</span>` : '')}
${bindableValue(target.min_brightness_threshold, 0) > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} &lt;${bindableValue(target.min_brightness_threshold, 0)} → off</span>` : ''}
</div>
${renderTagChips(target.tags)}
@@ -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) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
let _vsTypeIconSelect: IconSelect | null = null;
let _waveformIconSelect: IconSelect | null = null;
let _vsColorEasingIconSelect: IconSelect | null = null;
const _WAVEFORM_SVG = {
sine: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 12 Q15 -4 30 12 Q45 28 60 12"/></svg>',
@@ -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}
<span class="stream-card-prop">${ICON_REFRESH} ${src.scene_behavior || 'complement'}</span>
`;
} else if (src.source_type === 'static_color') {
const rgb = (src as any).color || [255, 255, 255];
const hex = rgbArrayToHex(rgb);
propsHtml = `<span class="stream-card-prop"><span style="display:inline-block;width:12px;height:12px;background:${hex};border:1px solid #888;border-radius:2px;vertical-align:middle"></span> ${hex}</span>`;
} else if (src.source_type === 'animated_color') {
const colors = (src as any).colors || [];
propsHtml = `
<span class="stream-card-prop">${ICON_ACTIVITY} ${colors.length} ${t('value_source.animated_color.color_count')}</span>
<span class="stream-card-prop">${ICON_TIMER} ${(src as any).speed ?? 10} cpm</span>
`;
} else if (src.source_type === 'adaptive_time_color') {
const pts = ((src as any).schedule || []).length;
propsHtml = `<span class="stream-card-prop">${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}</span>`;
} 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 = `
<span class="stream-card-prop">${ICON_HOME} ${escapeHtml(haName)}</span>
<span class="stream-card-prop">${ICON_LINK} ${escapeHtml(entityId)}${attr ? '.' + escapeHtml(attr) : ''}</span>
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${(src as any).min_ha_value ?? 0}\u2013${(src as any).max_ha_value ?? 100}</span>
`;
} 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 = `
<span class="stream-card-prop">${ICON_LINK} ${escapeHtml(inputName)}</span>
<span class="stream-card-prop">${ICON_RAINBOW} ${escapeHtml(gradName)}</span>
<div style="height:8px;border-radius:4px;margin:4px 0;background:${gradientCss};"></div>
`;
} 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 = `
<span class="stream-card-prop">${ICON_DROPLETS} ${escapeHtml(cssName)}</span>
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} LED ${rangeLabel}</span>
`;
}
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) => `
<div class="schedule-row" style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<input type="color" class="animated-color-input" value="${c}" data-idx="${i}"
onchange="_animatedColors[${i}] = this.value">
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeAnimatedColor(${i})">${ICON_TRASH}</button>
</div>
`).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) => `
<div class="schedule-row" style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<input type="time" class="color-schedule-time" value="${p.time}" data-idx="${i}">
<input type="color" class="color-schedule-color" value="${p.color}" data-idx="${i}">
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeColorSchedulePoint(${i})">${ICON_TRASH}</button>
</div>
`).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) =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).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<void> {
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) =>
`<option value="${e.entity_id}"${e.entity_id === selectedId ? ' selected' : ''}>${escapeHtml(e.friendly_name || e.entity_id)}</option>`
).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) =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).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) =>
`<option value="${g.id}"${g.id === selectedId ? ' selected' : ''}>${escapeHtml(g.name)}</option>`
).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
? `<span style="display:inline-block;width:80px;height:16px;border-radius:3px;background:linear-gradient(to right,${stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ')});flex-shrink:0"></span>`
: 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) =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).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'),
});
}
}
+5 -1
View File
@@ -23,7 +23,7 @@ interface Window {
// ─── Visual effects (called from inline <script>) ───
_updateBgAnimAccent: (accent: string) => void;
_updateBgAnimTheme: (dark: boolean) => void;
_updateTabIndicator: () => void;
_updateTabIndicator: (tabName?: string) => void;
// ─── Core / UI ───
toggleHint: (...args: any[]) => any;
@@ -286,6 +286,10 @@ startTargetOverlay: (...args: any[]) => any;
onValueSourceTypeChange: (...args: any[]) => any;
onDaylightVSRealTimeChange: (...args: any[]) => any;
addSchedulePoint: (...args: any[]) => any;
addAnimatedColor: (...args: any[]) => any;
removeAnimatedColor: (...args: any[]) => any;
addColorSchedulePoint: (...args: any[]) => any;
removeColorSchedulePoint: (...args: any[]) => any;
testValueSource: (...args: any[]) => any;
closeTestValueSourceModal: (...args: any[]) => any;
+233 -76
View File
@@ -24,6 +24,25 @@ export function bindableSourceId(b: BindableFloat | undefined): string {
return b.source_id ?? '';
}
// ── Bindable Color ──────────────────────────────────────────
// An RGB color that is either static ([R,G,B] array) or bound to a color value source.
export type BindableColor = number[] | { color: number[]; source_id: string };
/** Extract the static [R,G,B] from a BindableColor. */
export function bindableColor(b: BindableColor | undefined, fallback: number[]): number[] {
if (b === undefined || b === null) return fallback;
if (Array.isArray(b)) return b;
return b.color ?? fallback;
}
/** Extract the source_id from a BindableColor (empty string = not bound). */
export function bindableColorSourceId(b: BindableColor | undefined): string {
if (b === undefined || b === null) return '';
if (Array.isArray(b)) return '';
return b.source_id ?? '';
}
// ── Device ────────────────────────────────────────────────────
export type DeviceType =
@@ -66,7 +85,14 @@ export interface Device {
export type TargetType = 'led' | 'ha_light';
export interface OutputTarget {
export interface HALightMapping {
entity_id: string;
led_start: number;
led_end: number;
brightness_scale: BindableFloat;
}
interface OutputTargetBase {
id: string;
name: string;
target_type: TargetType;
@@ -74,32 +100,34 @@ export interface OutputTarget {
tags: string[];
created_at: string;
updated_at: string;
}
// LED target fields
device_id?: string;
color_strip_source_id?: string;
export interface LedOutputTarget extends OutputTargetBase {
target_type: 'led';
device_id: string;
color_strip_source_id: string;
brightness?: BindableFloat;
fps?: BindableFloat;
keepalive_interval?: number;
state_check_interval?: number;
keepalive_interval: number;
state_check_interval: number;
min_brightness_threshold?: BindableFloat;
adaptive_fps?: boolean;
protocol?: string;
adaptive_fps: boolean;
protocol: string;
}
// HA light target fields
ha_source_id?: string;
export interface HALightOutputTarget extends OutputTargetBase {
target_type: 'ha_light';
ha_source_id: string;
color_strip_source_id: string;
brightness?: BindableFloat;
ha_light_mappings?: HALightMapping[];
update_rate?: BindableFloat;
transition?: BindableFloat;
color_tolerance?: BindableFloat;
min_brightness_threshold?: BindableFloat;
}
export interface HALightMapping {
entity_id: string;
led_start: number;
led_end: number;
brightness_scale: BindableFloat;
}
export type OutputTarget = LedOutputTarget | HALightOutputTarget;
// ── Color Strip Source ────────────────────────────────────────
@@ -188,8 +216,8 @@ export interface ColorStripSource {
interpolation_mode?: string;
calibration?: Calibration;
// Static
color?: number[];
// Static / Effect / Candlelight
color?: BindableColor;
// Gradient
stops?: ColorStop[];
@@ -214,21 +242,21 @@ export interface ColorStripSource {
visualization_mode?: string;
audio_source_id?: string;
sensitivity?: BindableFloat;
color_peak?: number[];
color_peak?: BindableColor;
// Animation
animation?: AnimationConfig;
speed?: BindableFloat;
// API Input
fallback_color?: number[];
fallback_color?: BindableColor;
timeout?: BindableFloat;
interpolation?: string;
// Notification
notification_effect?: string;
duration_ms?: number;
default_color?: string;
default_color?: BindableColor | string;
app_colors?: Record<string, string>;
app_filter_mode?: string;
app_filter_list?: string[];
@@ -280,79 +308,193 @@ export interface PatternTemplate {
export type ValueSourceType =
| 'static' | 'animated' | 'audio'
| 'adaptive_time' | 'adaptive_scene' | 'daylight';
| 'adaptive_time' | 'adaptive_scene' | 'daylight'
| 'static_color' | 'animated_color' | 'adaptive_time_color'
| 'ha_entity' | 'gradient_map' | 'css_extract';
export interface SchedulePoint {
time: string;
value: number;
}
export interface ValueSource {
export interface ColorSchedulePoint {
time: string;
color: number[];
}
interface ValueSourceBase {
id: string;
name: string;
source_type: ValueSourceType;
return_type: 'float' | 'color';
description?: string;
tags: string[];
created_at: string;
updated_at: string;
// Static
value?: number;
// Animated
waveform?: string;
speed?: number;
min_value?: number;
max_value?: number;
// Audio
audio_source_id?: string;
mode?: string;
sensitivity?: number;
smoothing?: number;
auto_gain?: boolean;
// Adaptive
schedule?: SchedulePoint[];
picture_source_id?: string;
scene_behavior?: string;
// Daylight
use_real_time?: boolean;
latitude?: number;
}
export interface StaticValueSource extends ValueSourceBase {
source_type: 'static';
return_type: 'float';
value: number;
}
export interface AnimatedValueSource extends ValueSourceBase {
source_type: 'animated';
return_type: 'float';
waveform: string;
speed: number;
min_value: number;
max_value: number;
}
export interface AudioValueSource extends ValueSourceBase {
source_type: 'audio';
return_type: 'float';
audio_source_id: string;
mode: string;
sensitivity: number;
smoothing: number;
min_value: number;
max_value: number;
auto_gain: boolean;
}
export interface AdaptiveTimeValueSource extends ValueSourceBase {
source_type: 'adaptive_time';
return_type: 'float';
schedule: SchedulePoint[];
min_value: number;
max_value: number;
}
export interface AdaptiveSceneValueSource extends ValueSourceBase {
source_type: 'adaptive_scene';
return_type: 'float';
picture_source_id: string;
scene_behavior: string;
sensitivity: number;
smoothing: number;
min_value: number;
max_value: number;
}
export interface DaylightValueSource extends ValueSourceBase {
source_type: 'daylight';
return_type: 'float';
speed: number;
use_real_time: boolean;
latitude: number;
min_value: number;
max_value: number;
}
export interface StaticColorValueSource extends ValueSourceBase {
source_type: 'static_color';
return_type: 'color';
color: number[];
}
export interface AnimatedColorValueSource extends ValueSourceBase {
source_type: 'animated_color';
return_type: 'color';
colors: number[][];
speed: number;
easing: string;
}
export interface AdaptiveTimeColorValueSource extends ValueSourceBase {
source_type: 'adaptive_time_color';
return_type: 'color';
schedule: ColorSchedulePoint[];
}
export interface HAEntityValueSource extends ValueSourceBase {
source_type: 'ha_entity';
return_type: 'float';
ha_source_id: string;
entity_id: string;
attribute: string;
min_ha_value: number;
max_ha_value: number;
smoothing: number;
}
export interface GradientMapValueSource extends ValueSourceBase {
source_type: 'gradient_map';
return_type: 'color';
value_source_id: string;
gradient_id: string;
easing: string;
}
export interface CSSExtractValueSource extends ValueSourceBase {
source_type: 'css_extract';
return_type: 'color';
color_strip_source_id: string;
led_start: number;
led_end: number;
}
export type ValueSource =
| StaticValueSource
| AnimatedValueSource
| AudioValueSource
| AdaptiveTimeValueSource
| AdaptiveSceneValueSource
| DaylightValueSource
| StaticColorValueSource
| AnimatedColorValueSource
| AdaptiveTimeColorValueSource
| HAEntityValueSource
| GradientMapValueSource
| CSSExtractValueSource;
// ── Audio Source ───────────────────────────────────────────────
export interface AudioSource {
export type AudioSourceType = 'multichannel' | 'mono' | 'band_extract';
interface AudioSourceBase {
id: string;
name: string;
source_type: 'multichannel' | 'mono' | 'band_extract';
source_type: AudioSourceType;
description?: string;
tags: string[];
created_at: string;
updated_at: string;
// Multichannel
device_index?: number;
is_loopback?: boolean;
audio_template_id?: string;
// Mono
audio_source_id?: string;
channel?: string;
// Band Extract
band?: string;
freq_low?: number;
freq_high?: number;
}
export interface MultichannelAudioSource extends AudioSourceBase {
source_type: 'multichannel';
device_index: number;
is_loopback: boolean;
audio_template_id?: string;
}
export interface MonoAudioSource extends AudioSourceBase {
source_type: 'mono';
audio_source_id: string;
channel: string;
}
export interface BandExtractAudioSource extends AudioSourceBase {
source_type: 'band_extract';
audio_source_id: string;
band: string;
freq_low: number;
freq_high: number;
}
export type AudioSource =
| MultichannelAudioSource
| MonoAudioSource
| BandExtractAudioSource;
// ── Picture Source ─────────────────────────────────────────────
export type PictureSourceType = 'raw' | 'processed' | 'static_image' | 'video';
export interface PictureSource {
interface PictureSourceBase {
id: string;
name: string;
stream_type: PictureSourceType;
@@ -360,29 +502,44 @@ export interface PictureSource {
tags: string[];
created_at: string;
updated_at: string;
}
// Raw
display_index?: number;
capture_template_id?: string;
target_fps?: number;
export interface RawPictureSource extends PictureSourceBase {
stream_type: 'raw';
display_index: number;
capture_template_id: string;
target_fps: number;
}
// Processed
source_stream_id?: string;
postprocessing_template_id?: string;
export interface ProcessedPictureSource extends PictureSourceBase {
stream_type: 'processed';
source_stream_id: string;
postprocessing_template_id: string;
}
// Static image
export interface StaticImagePictureSource extends PictureSourceBase {
stream_type: 'static_image';
image_asset_id?: string;
}
// Video
export interface VideoPictureSource extends PictureSourceBase {
stream_type: 'video';
video_asset_id?: string;
loop?: boolean;
playback_speed?: number;
loop: boolean;
playback_speed: number;
start_time?: number;
end_time?: number;
resolution_limit?: number;
clock_id?: string;
target_fps: number;
}
export type PictureSource =
| RawPictureSource
| ProcessedPictureSource
| StaticImagePictureSource
| VideoPictureSource;
// ── Scene Preset ──────────────────────────────────────────────
export interface TargetSnapshot {
@@ -1452,6 +1452,53 @@
"value_source.type.adaptive_scene.desc": "Adjusts by scene content",
"value_source.type.daylight": "Daylight Cycle",
"value_source.type.daylight.desc": "Brightness follows day/night cycle",
"value_source.type.static_color": "Static Color",
"value_source.type.static_color.desc": "Fixed RGB color",
"value_source.type.animated_color": "Animated Color",
"value_source.type.animated_color.desc": "Cycles through colors",
"value_source.type.adaptive_time_color": "Time Color",
"value_source.type.adaptive_time_color.desc": "24-hour color schedule",
"value_source.type.ha_entity": "HA Entity",
"value_source.type.ha_entity.desc": "Reads value from a Home Assistant sensor",
"value_source.type.gradient_map": "Gradient Map",
"value_source.type.gradient_map.desc": "Maps numeric value through a color gradient",
"value_source.type.css_extract": "Strip Extract",
"value_source.type.css_extract.desc": "Extracts color from a color strip source",
"value_source.ha_source": "HA Connection:",
"value_source.ha_source.hint": "Home Assistant connection to read entities from",
"value_source.entity_id": "Entity:",
"value_source.entity_id.hint": "HA entity ID (e.g. sensor.temperature)",
"value_source.attribute": "Attribute (optional):",
"value_source.attribute.hint": "Read a specific attribute instead of the entity state",
"value_source.min_ha_value": "Min HA Value:",
"value_source.min_ha_value.hint": "Raw HA value that maps to 0% output",
"value_source.max_ha_value": "Max HA Value:",
"value_source.max_ha_value.hint": "Raw HA value that maps to 100% output",
"value_source.input_source": "Input Value Source:",
"value_source.input_source.hint": "Float value source (0-1) to map through the gradient",
"value_source.gradient_stops": "Gradient:",
"value_source.gradient_stops.hint": "Color stops for the gradient. Position 0 = input value 0, position 1 = input value 1",
"value_source.easing": "Interpolation:",
"value_source.easing.hint": "How colors blend between stops",
"value_source.css_source": "Color Strip Source:",
"value_source.css_source.hint": "Color strip source to extract color from",
"value_source.led_start": "LED Start:",
"value_source.led_start.hint": "First LED in the range (0-based)",
"value_source.led_end": "LED End:",
"value_source.led_end.hint": "Last LED in the range (-1 = whole strip)",
"value_source.filter.all": "All",
"value_source.filter.float": "Numeric",
"value_source.filter.color": "Color",
"value_source.static_color.color": "Color:",
"value_source.animated_color.colors": "Colors:",
"value_source.animated_color.speed": "Speed (cpm):",
"value_source.animated_color.easing": "Easing:",
"value_source.animated_color.easing.linear": "Linear",
"value_source.animated_color.easing.linear.desc": "Smooth blend between colors",
"value_source.animated_color.easing.step": "Step",
"value_source.animated_color.easing.step.desc": "Instant jump between colors",
"value_source.animated_color.color_count": "colors",
"value_source.adaptive_time_color.schedule": "Color Schedule:",
"value_source.daylight.speed": "Speed:",
"value_source.daylight.speed.hint": "Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.",
"value_source.daylight.use_real_time": "Use Real Time:",
@@ -1530,6 +1577,8 @@
"test.frames": "Frames",
"test.fps": "FPS",
"test.avg_capture": "Avg",
"targets.brightness": "Brightness:",
"targets.brightness.hint": "Output brightness multiplier (01). Can be bound to a value source for dynamic control.",
"targets.brightness_vs": "Brightness Source:",
"targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)",
"targets.brightness_vs.none": "None (device brightness)",
@@ -1440,6 +1440,8 @@
"test.frames": "Кадры",
"test.fps": "Кадр/с",
"test.avg_capture": "Сред",
"targets.brightness": "Яркость:",
"targets.brightness.hint": "Множитель яркости (0–1). Можно привязать к источнику значений для динамического управления.",
"targets.brightness_vs": "Источник яркости:",
"targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)",
"targets.brightness_vs.none": "Нет (яркость устройства)",
@@ -1440,6 +1440,8 @@
"test.frames": "帧数",
"test.fps": "帧率",
"test.avg_capture": "平均",
"targets.brightness": "亮度:",
"targets.brightness.hint": "输出亮度乘数(0–1)。可绑定到值源进行动态控制。",
"targets.brightness_vs": "亮度源:",
"targets.brightness_vs.hint": "可选的值源,每帧动态控制亮度(覆盖设备亮度)",
"targets.brightness_vs.none": "无(设备亮度)",
@@ -100,6 +100,76 @@ class BindableFloat:
return bool(self.source_id)
@dataclass
class BindableColor:
"""An RGB color that is either static or driven by a color ValueSource."""
color: list # [R, G, B] static fallback
source_id: str = "" # empty → use static color
def __post_init__(self):
if not isinstance(self.color, list) or len(self.color) != 3:
self.color = [255, 255, 255]
def to_dict(self):
"""Serialize: plain [R,G,B] when unbound, dict when bound."""
if not self.source_id:
return list(self.color)
return {"color": list(self.color), "source_id": self.source_id}
@classmethod
def from_raw(cls, data, *, default: list | None = None) -> "BindableColor":
"""Deserialize from [R,G,B] array or dict."""
if default is None:
default = [255, 255, 255]
if data is None:
return cls(color=list(default))
if isinstance(data, list) and len(data) == 3:
return cls(color=list(data))
if isinstance(data, dict):
raw_color = data.get("color", default)
color = (
list(raw_color)
if isinstance(raw_color, list) and len(raw_color) == 3
else list(default)
)
return cls(color=color, source_id=data.get("source_id") or "")
return cls(color=list(default))
def apply_update(self, raw) -> "BindableColor":
"""Return a new BindableColor from an update payload."""
if raw is None:
return self
if isinstance(raw, list) and len(raw) == 3:
return BindableColor(color=list(raw), source_id=self.source_id)
if isinstance(raw, dict):
color = raw.get("color", self.color)
if isinstance(color, list) and len(color) == 3:
color = list(color)
else:
color = list(self.color)
return BindableColor(
color=color,
source_id=raw.get("source_id", self.source_id),
)
return self
@property
def is_bound(self) -> bool:
return bool(self.source_id)
def bcolor(v, default: list | None = None) -> list:
"""Extract the static [R,G,B] from a value that may be BindableColor or plain list."""
if default is None:
default = [255, 255, 255]
if isinstance(v, BindableColor):
return list(v.color)
if isinstance(v, list) and len(v) == 3:
return list(v)
return list(default)
def bfloat(v, default: float = 0.0) -> float:
"""Extract the static float from a value that may be BindableFloat or plain number.
@@ -26,10 +26,27 @@ from wled_controller.core.capture.calibration import (
calibration_from_dict,
calibration_to_dict,
)
from wled_controller.storage.bindable import BindableFloat
from wled_controller.storage.bindable import BindableColor, BindableFloat
from wled_controller.storage.utils import resolve_ref
def _parse_default_color(raw) -> BindableColor:
"""Parse default_color from legacy hex string or BindableColor format."""
if raw is None:
return BindableColor([255, 255, 255])
if isinstance(raw, str):
# Legacy hex string like "#FFFFFF"
h = raw.lstrip("#")
if len(h) == 6:
try:
rgb = [int(h[i : i + 2], 16) for i in (0, 2, 4)]
return BindableColor(rgb)
except ValueError:
pass
return BindableColor([255, 255, 255])
return BindableColor.from_raw(raw, default=[255, 255, 255])
def _validate_rgb(value, default: list) -> list:
"""Return value if it's a 3-element list, otherwise return default."""
return value if isinstance(value, list) and len(value) == 3 else list(default)
@@ -349,23 +366,22 @@ class StaticColorStripSource(ColorStripSource):
a PictureColorStripSource is being configured.
"""
color: list = field(default_factory=lambda: [255, 255, 255]) # [R, G, B]
color: BindableColor = field(default_factory=lambda: BindableColor([255, 255, 255]))
animation: Optional[dict] = None # {"enabled": bool, "type": str, "speed": float} or None
def to_dict(self) -> dict:
d = super().to_dict()
d["color"] = list(self.color)
d["color"] = self.color.to_dict()
d["animation"] = self.animation
return d
@classmethod
def from_dict(cls, data: dict) -> "StaticColorStripSource":
common = _parse_css_common(data)
color = _validate_rgb(data.get("color"), [255, 255, 255])
return cls(
**common,
source_type="static",
color=color,
color=BindableColor.from_raw(data.get("color"), default=[255, 255, 255]),
animation=data.get("animation"),
)
@@ -385,7 +401,6 @@ class StaticColorStripSource(ColorStripSource):
animation=None,
**_kwargs,
):
rgb = _validate_rgb(color, [255, 255, 255])
return cls(
id=id,
name=name,
@@ -395,15 +410,14 @@ class StaticColorStripSource(ColorStripSource):
description=description,
clock_id=clock_id,
tags=tags or [],
color=rgb,
color=BindableColor.from_raw(color, default=[255, 255, 255]),
animation=animation,
)
def apply_update(self, **kwargs) -> None:
color = kwargs.get("color")
if color is not None:
if isinstance(color, list) and len(color) == 3:
self.color = color
self.color = self.color.apply_update(color)
if kwargs.get("animation") is not None:
self.animation = kwargs["animation"]
@@ -591,8 +605,8 @@ class EffectColorStripSource(ColorStripSource):
effect_type: str = "fire" # fire | meteor | plasma | noise | aurora + new types
palette: str = "fire" # legacy palette name (kept for migration)
gradient_id: Optional[str] = None # references a Gradient entity (preferred over palette)
color: list = field(
default_factory=lambda: [255, 80, 0]
color: BindableColor = field(
default_factory=lambda: BindableColor([255, 80, 0])
) # [R,G,B] for meteor/comet/bouncing_ball head
intensity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
scale: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
@@ -604,7 +618,7 @@ class EffectColorStripSource(ColorStripSource):
d["effect_type"] = self.effect_type
d["palette"] = self.palette
d["gradient_id"] = self.gradient_id
d["color"] = list(self.color)
d["color"] = self.color.to_dict()
d["intensity"] = self.intensity.to_dict()
d["scale"] = self.scale.to_dict()
d["mirror"] = self.mirror
@@ -614,14 +628,13 @@ class EffectColorStripSource(ColorStripSource):
@classmethod
def from_dict(cls, data: dict) -> "EffectColorStripSource":
common = _parse_css_common(data)
color = _validate_rgb(data.get("color"), [255, 80, 0])
return cls(
**common,
source_type="effect",
effect_type=data.get("effect_type") or "fire",
palette=data.get("palette") or "fire",
gradient_id=data.get("gradient_id"),
color=color,
color=BindableColor.from_raw(data.get("color"), default=[255, 80, 0]),
intensity=BindableFloat.from_raw(data.get("intensity"), default=1.0),
scale=BindableFloat.from_raw(data.get("scale"), default=1.0),
mirror=bool(data.get("mirror", False)),
@@ -650,7 +663,6 @@ class EffectColorStripSource(ColorStripSource):
custom_palette=None,
**_kwargs,
):
rgb = _validate_rgb(color, [255, 80, 0])
return cls(
id=id,
name=name,
@@ -663,7 +675,7 @@ class EffectColorStripSource(ColorStripSource):
effect_type=effect_type or "fire",
palette=palette or "fire",
gradient_id=gradient_id,
color=rgb,
color=BindableColor.from_raw(color, default=[255, 80, 0]),
intensity=BindableFloat.from_raw(intensity, default=1.0),
scale=BindableFloat.from_raw(scale, default=1.0),
mirror=bool(mirror),
@@ -678,8 +690,8 @@ class EffectColorStripSource(ColorStripSource):
if "gradient_id" in kwargs:
self.gradient_id = kwargs["gradient_id"]
color = kwargs.get("color")
if color is not None and isinstance(color, list) and len(color) == 3:
self.color = color
if color is not None:
self.color = self.color.apply_update(color)
if kwargs.get("intensity") is not None:
self.intensity = self.intensity.apply_update(kwargs["intensity"])
if kwargs.get("scale") is not None:
@@ -706,8 +718,8 @@ class AudioColorStripSource(ColorStripSource):
smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
palette: str = "rainbow" # legacy palette name (kept for migration)
gradient_id: Optional[str] = None # references a Gradient entity (preferred)
color: list = field(default_factory=lambda: [0, 255, 0]) # base RGB for VU meter
color_peak: list = field(default_factory=lambda: [255, 0, 0]) # peak RGB for VU meter
color: BindableColor = field(default_factory=lambda: BindableColor([0, 255, 0]))
color_peak: BindableColor = field(default_factory=lambda: BindableColor([255, 0, 0]))
led_count: int = 0 # 0 = use device LED count
mirror: bool = False # mirror spectrum from center outward
@@ -719,8 +731,8 @@ class AudioColorStripSource(ColorStripSource):
d["smoothing"] = self.smoothing.to_dict()
d["palette"] = self.palette
d["gradient_id"] = self.gradient_id
d["color"] = list(self.color)
d["color_peak"] = list(self.color_peak)
d["color"] = self.color.to_dict()
d["color_peak"] = self.color_peak.to_dict()
d["led_count"] = self.led_count
d["mirror"] = self.mirror
return d
@@ -728,8 +740,6 @@ class AudioColorStripSource(ColorStripSource):
@classmethod
def from_dict(cls, data: dict) -> "AudioColorStripSource":
common = _parse_css_common(data)
color = _validate_rgb(data.get("color"), [0, 255, 0])
color_peak = _validate_rgb(data.get("color_peak"), [255, 0, 0])
return cls(
**common,
source_type="audio",
@@ -739,8 +749,8 @@ class AudioColorStripSource(ColorStripSource):
smoothing=BindableFloat.from_raw(data.get("smoothing"), default=0.3),
palette=data.get("palette") or "rainbow",
gradient_id=data.get("gradient_id"),
color=color,
color_peak=color_peak,
color=BindableColor.from_raw(data.get("color"), default=[0, 255, 0]),
color_peak=BindableColor.from_raw(data.get("color_peak"), default=[255, 0, 0]),
led_count=data.get("led_count") or 0,
mirror=bool(data.get("mirror", False)),
)
@@ -769,8 +779,6 @@ class AudioColorStripSource(ColorStripSource):
mirror=False,
**_kwargs,
):
rgb = _validate_rgb(color, [0, 255, 0])
peak = _validate_rgb(color_peak, [255, 0, 0])
return cls(
id=id,
name=name,
@@ -786,8 +794,8 @@ class AudioColorStripSource(ColorStripSource):
smoothing=BindableFloat.from_raw(smoothing, default=0.3),
palette=palette or "rainbow",
gradient_id=gradient_id,
color=rgb,
color_peak=peak,
color=BindableColor.from_raw(color, default=[0, 255, 0]),
color_peak=BindableColor.from_raw(color_peak, default=[255, 0, 0]),
led_count=led_count,
mirror=bool(mirror),
)
@@ -807,11 +815,11 @@ class AudioColorStripSource(ColorStripSource):
if "gradient_id" in kwargs:
self.gradient_id = kwargs["gradient_id"]
color = kwargs.get("color")
if color is not None and isinstance(color, list) and len(color) == 3:
self.color = color
if color is not None:
self.color = self.color.apply_update(color)
color_peak = kwargs.get("color_peak")
if color_peak is not None and isinstance(color_peak, list) and len(color_peak) == 3:
self.color_peak = color_peak
if color_peak is not None:
self.color_peak = self.color_peak.apply_update(color_peak)
if kwargs.get("led_count") is not None:
self.led_count = kwargs["led_count"]
if kwargs.get("mirror") is not None:
@@ -963,13 +971,13 @@ class ApiInputColorStripSource(ColorStripSource):
LED count auto-sizes from the connected device via configure().
"""
fallback_color: list = field(default_factory=lambda: [0, 0, 0]) # [R, G, B]
fallback_color: BindableColor = field(default_factory=lambda: BindableColor([0, 0, 0]))
timeout: BindableFloat = field(default_factory=lambda: BindableFloat(5.0))
interpolation: str = "linear" # none | linear | nearest
def to_dict(self) -> dict:
d = super().to_dict()
d["fallback_color"] = list(self.fallback_color)
d["fallback_color"] = self.fallback_color.to_dict()
d["timeout"] = self.timeout.to_dict()
d["interpolation"] = self.interpolation
return d
@@ -977,7 +985,7 @@ class ApiInputColorStripSource(ColorStripSource):
@classmethod
def from_dict(cls, data: dict) -> "ApiInputColorStripSource":
common = _parse_css_common(data)
fallback_color = _validate_rgb(data.get("fallback_color"), [0, 0, 0])
fallback_color = BindableColor.from_raw(data.get("fallback_color"), default=[0, 0, 0])
interpolation = data.get("interpolation", "linear")
if interpolation not in ("none", "linear", "nearest"):
interpolation = "linear"
@@ -1006,7 +1014,7 @@ class ApiInputColorStripSource(ColorStripSource):
interpolation=None,
**_kwargs,
):
fb = _validate_rgb(fallback_color, [0, 0, 0])
fb = BindableColor.from_raw(fallback_color, default=[0, 0, 0])
interp = interpolation if interpolation in ("none", "linear", "nearest") else "linear"
return cls(
id=id,
@@ -1024,12 +1032,8 @@ class ApiInputColorStripSource(ColorStripSource):
def apply_update(self, **kwargs) -> None:
fallback_color = kwargs.get("fallback_color")
if (
fallback_color is not None
and isinstance(fallback_color, list)
and len(fallback_color) == 3
):
self.fallback_color = fallback_color
if fallback_color is not None:
self.fallback_color = self.fallback_color.apply_update(fallback_color)
if kwargs.get("timeout") is not None:
self.timeout = self.timeout.apply_update(kwargs["timeout"])
interpolation = kwargs.get("interpolation")
@@ -1050,7 +1054,7 @@ class NotificationColorStripSource(ColorStripSource):
notification_effect: str = "flash" # flash | pulse | sweep
duration_ms: BindableFloat = field(default_factory=lambda: BindableFloat(1500.0))
default_color: str = "#FFFFFF" # hex color for notifications without app match
default_color: BindableColor = field(default_factory=lambda: BindableColor([255, 255, 255]))
app_colors: dict = field(default_factory=dict) # app name -> hex color
app_filter_mode: str = "off" # off | whitelist | blacklist
app_filter_list: list = field(default_factory=list) # app names for filter
@@ -1065,7 +1069,7 @@ class NotificationColorStripSource(ColorStripSource):
d = super().to_dict()
d["notification_effect"] = self.notification_effect
d["duration_ms"] = self.duration_ms.to_dict()
d["default_color"] = self.default_color
d["default_color"] = self.default_color.to_dict()
d["app_colors"] = dict(self.app_colors)
d["app_filter_mode"] = self.app_filter_mode
d["app_filter_list"] = list(self.app_filter_list)
@@ -1086,7 +1090,7 @@ class NotificationColorStripSource(ColorStripSource):
source_type="notification",
notification_effect=data.get("notification_effect") or "flash",
duration_ms=BindableFloat.from_raw(data.get("duration_ms"), default=1500.0),
default_color=data.get("default_color") or "#FFFFFF",
default_color=_parse_default_color(data.get("default_color")),
app_colors=raw_app_colors if isinstance(raw_app_colors, dict) else {},
app_filter_mode=data.get("app_filter_mode") or "off",
app_filter_list=raw_app_filter_list if isinstance(raw_app_filter_list, list) else [],
@@ -1131,7 +1135,7 @@ class NotificationColorStripSource(ColorStripSource):
tags=tags or [],
notification_effect=notification_effect or "flash",
duration_ms=BindableFloat.from_raw(duration_ms, default=1500.0),
default_color=default_color or "#FFFFFF",
default_color=_parse_default_color(default_color),
app_colors=app_colors if isinstance(app_colors, dict) else {},
app_filter_mode=app_filter_mode or "off",
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
@@ -1147,7 +1151,11 @@ class NotificationColorStripSource(ColorStripSource):
if kwargs.get("duration_ms") is not None:
self.duration_ms = self.duration_ms.apply_update(kwargs["duration_ms"])
if kwargs.get("default_color") is not None:
self.default_color = kwargs["default_color"]
raw_dc = kwargs["default_color"]
if isinstance(raw_dc, str):
self.default_color = _parse_default_color(raw_dc)
else:
self.default_color = self.default_color.apply_update(raw_dc)
app_colors = kwargs.get("app_colors")
if app_colors is not None and isinstance(app_colors, dict):
self.app_colors = app_colors
@@ -1259,7 +1267,7 @@ class CandlelightColorStripSource(ColorStripSource):
LED count auto-sizes from the connected device.
"""
color: list = field(default_factory=lambda: [255, 147, 41]) # warm candle base [R,G,B]
color: BindableColor = field(default_factory=lambda: BindableColor([255, 147, 41]))
intensity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
num_candles: int = 3 # number of independent candle sources
speed: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
@@ -1268,7 +1276,7 @@ class CandlelightColorStripSource(ColorStripSource):
def to_dict(self) -> dict:
d = super().to_dict()
d["color"] = list(self.color)
d["color"] = self.color.to_dict()
d["intensity"] = self.intensity.to_dict()
d["num_candles"] = self.num_candles
d["speed"] = self.speed.to_dict()
@@ -1279,11 +1287,10 @@ class CandlelightColorStripSource(ColorStripSource):
@classmethod
def from_dict(cls, data: dict) -> "CandlelightColorStripSource":
common = _parse_css_common(data)
color = _validate_rgb(data.get("color"), [255, 147, 41])
return cls(
**common,
source_type="candlelight",
color=color,
color=BindableColor.from_raw(data.get("color"), default=[255, 147, 41]),
intensity=BindableFloat.from_raw(data.get("intensity"), default=1.0),
num_candles=int(data.get("num_candles") or 3),
speed=BindableFloat.from_raw(data.get("speed"), default=1.0),
@@ -1311,7 +1318,6 @@ class CandlelightColorStripSource(ColorStripSource):
candle_type=None,
**_kwargs,
):
rgb = _validate_rgb(color, [255, 147, 41])
return cls(
id=id,
name=name,
@@ -1321,7 +1327,7 @@ class CandlelightColorStripSource(ColorStripSource):
description=description,
clock_id=clock_id,
tags=tags or [],
color=rgb,
color=BindableColor.from_raw(color, default=[255, 147, 41]),
intensity=BindableFloat.from_raw(intensity, default=1.0),
num_candles=int(num_candles) if num_candles is not None else 3,
speed=BindableFloat.from_raw(speed, default=1.0),
@@ -1335,8 +1341,8 @@ class CandlelightColorStripSource(ColorStripSource):
def apply_update(self, **kwargs) -> None:
color = kwargs.get("color")
if color is not None and isinstance(color, list) and len(color) == 3:
self.color = color
if color is not None:
self.color = self.color.apply_update(color)
if kwargs.get("intensity") is not None:
self.intensity = self.intensity.apply_update(kwargs["intensity"])
if kwargs.get("num_candles") is not None:
@@ -1,7 +1,9 @@
"""Value source data model with inheritance-based source types.
A ValueSource produces a scalar float (0.01.0) that can drive target
parameters like brightness. Six types:
A ValueSource produces either a scalar float (0.01.0) or an RGB color [R,G,B]
depending on ``return_type`` ("float" or "color").
Float types (return_type="float"):
StaticValueSource constant float value
AnimatedValueSource periodic waveform (sine, triangle, square, sawtooth)
AudioValueSource audio-reactive scalar (RMS, peak, beat detection)
@@ -9,6 +11,11 @@ parameters like brightness. Six types:
adaptive_time interpolates brightness along a 24-hour schedule
adaptive_scene derives brightness from a picture source's frame luminance
DaylightValueSource brightness based on simulated or real-time daylight cycle
Color types (return_type="color"):
StaticColorValueSource constant RGB color
AnimatedColorValueSource cycles through a color list over time
AdaptiveTimeColorValueSource 24-hour schedule of RGB colors
"""
from dataclasses import dataclass, field
@@ -22,7 +29,9 @@ class ValueSource:
id: str
name: str
source_type: str # "static" | "animated" | "audio" | "adaptive_time" | "adaptive_scene" | "daylight"
source_type: (
str # "static" | "animated" | "audio" | "adaptive_time" | "adaptive_scene" | "daylight"
)
created_at: datetime
updated_at: datetime
description: Optional[str] = None
@@ -70,15 +79,13 @@ def _parse_common_fields(data: dict) -> dict:
created_at = (
datetime.fromisoformat(raw_created)
if isinstance(raw_created, str)
else raw_created if isinstance(raw_created, datetime)
else datetime.now(timezone.utc)
else raw_created if isinstance(raw_created, datetime) else datetime.now(timezone.utc)
)
raw_updated = data.get("updated_at")
updated_at = (
datetime.fromisoformat(raw_updated)
if isinstance(raw_updated, str)
else raw_updated if isinstance(raw_updated, datetime)
else datetime.now(timezone.utc)
else raw_updated if isinstance(raw_updated, datetime) else datetime.now(timezone.utc)
)
return dict(
id=data["id"],
@@ -122,10 +129,10 @@ class AnimatedValueSource(ValueSource):
at the configured speed (cycles per minute).
"""
waveform: str = "sine" # sine | triangle | square | sawtooth
speed: float = 10.0 # cycles per minute (1.0120.0)
min_value: float = 0.0 # minimum output (0.01.0)
max_value: float = 1.0 # maximum output (0.01.0)
waveform: str = "sine" # sine | triangle | square | sawtooth
speed: float = 10.0 # cycles per minute (1.0120.0)
min_value: float = 0.0 # minimum output (0.01.0)
max_value: float = 1.0 # maximum output (0.01.0)
def to_dict(self) -> dict:
d = super().to_dict()
@@ -156,13 +163,13 @@ class AudioValueSource(ValueSource):
into a scalar value for brightness modulation.
"""
audio_source_id: str = "" # references an audio source (mono or multichannel)
mode: str = "rms" # rms | peak | beat
sensitivity: float = 1.0 # gain multiplier (0.120.0)
smoothing: float = 0.3 # temporal smoothing (0.01.0)
min_value: float = 0.0 # minimum output (0.01.0)
max_value: float = 1.0 # maximum output (0.01.0)
auto_gain: bool = False # auto-normalize audio levels to full range
audio_source_id: str = "" # references an audio source (mono or multichannel)
mode: str = "rms" # rms | peak | beat
sensitivity: float = 1.0 # gain multiplier (0.120.0)
smoothing: float = 0.3 # temporal smoothing (0.01.0)
min_value: float = 0.0 # minimum output (0.01.0)
max_value: float = 1.0 # maximum output (0.01.0)
auto_gain: bool = False # auto-normalize audio levels to full range
def to_dict(self) -> dict:
d = super().to_dict()
@@ -201,12 +208,12 @@ class AdaptiveValueSource(ValueSource):
"""
schedule: List[dict] = field(default_factory=list) # [{time: "HH:MM", value: 0.0-1.0}]
picture_source_id: str = "" # for scene mode
scene_behavior: str = "complement" # "complement" | "match"
sensitivity: float = 1.0 # gain multiplier (0.1-5.0)
smoothing: float = 0.3 # temporal smoothing (0.0-1.0)
min_value: float = 0.0 # output range min
max_value: float = 1.0 # output range max
picture_source_id: str = "" # for scene mode
scene_behavior: str = "complement" # "complement" | "match"
sensitivity: float = 1.0 # gain multiplier (0.1-5.0)
smoothing: float = 0.3 # temporal smoothing (0.0-1.0)
min_value: float = 0.0 # output range min
max_value: float = 1.0 # output range max
def to_dict(self) -> dict:
d = super().to_dict()
@@ -244,11 +251,11 @@ class DaylightValueSource(ValueSource):
scalar brightness from the simulated (or real-time) sky color luminance.
"""
speed: float = 1.0 # simulation speed (ignored when use_real_time)
use_real_time: bool = False # use wall clock instead of simulation
latitude: float = 50.0 # affects sunrise/sunset in real-time mode
min_value: float = 0.0 # output range min
max_value: float = 1.0 # output range max
speed: float = 1.0 # simulation speed (ignored when use_real_time)
use_real_time: bool = False # use wall clock instead of simulation
latitude: float = 50.0 # affects sunrise/sunset in real-time mode
min_value: float = 0.0 # output range min
max_value: float = 1.0 # output range max
def to_dict(self) -> dict:
d = super().to_dict()
@@ -273,6 +280,193 @@ class DaylightValueSource(ValueSource):
)
# =====================================================================
# Color value sources (return_type="color")
# =====================================================================
@dataclass
class StaticColorValueSource(ValueSource):
"""Color value source that outputs a constant RGB color."""
color: list = field(default_factory=lambda: [255, 255, 255]) # [R, G, B]
def to_dict(self) -> dict:
d = super().to_dict()
d["color"] = list(self.color)
d["return_type"] = "color"
return d
@classmethod
def from_dict(cls, data: dict) -> "StaticColorValueSource":
common = _parse_common_fields(data)
raw = data.get("color", [255, 255, 255])
color = list(raw) if isinstance(raw, list) and len(raw) == 3 else [255, 255, 255]
return cls(**common, source_type="static_color", color=color)
@dataclass
class AnimatedColorValueSource(ValueSource):
"""Color value source that cycles through a list of colors over time."""
colors: list = field(default_factory=lambda: [[255, 0, 0], [0, 255, 0], [0, 0, 255]])
speed: float = 10.0 # cycles per minute
easing: str = "linear" # linear | step
def to_dict(self) -> dict:
d = super().to_dict()
d["colors"] = [list(c) for c in self.colors]
d["speed"] = self.speed
d["easing"] = self.easing
d["return_type"] = "color"
return d
@classmethod
def from_dict(cls, data: dict) -> "AnimatedColorValueSource":
common = _parse_common_fields(data)
raw_colors = data.get("colors")
colors = (
[list(c) for c in raw_colors]
if isinstance(raw_colors, list) and len(raw_colors) >= 2
else [[255, 0, 0], [0, 255, 0], [0, 0, 255]]
)
return cls(
**common,
source_type="animated_color",
colors=colors,
speed=float(data.get("speed") or 10.0),
easing=data.get("easing") or "linear",
)
@dataclass
class AdaptiveTimeColorValueSource(ValueSource):
"""Color value source with a 24-hour schedule of RGB colors."""
schedule: List[dict] = field(default_factory=list) # [{"time": "HH:MM", "color": [R,G,B]}, ...]
def to_dict(self) -> dict:
d = super().to_dict()
d["schedule"] = self.schedule
d["return_type"] = "color"
return d
@classmethod
def from_dict(cls, data: dict) -> "AdaptiveTimeColorValueSource":
common = _parse_common_fields(data)
return cls(
**common,
source_type="adaptive_time_color",
schedule=data.get("schedule") or [],
)
@dataclass
class HAEntityValueSource(ValueSource):
"""Value source that reads numeric values from a Home Assistant entity.
Reads state or attribute from an HA entity, normalizes the raw value
to 0.01.0 using the configured min/max range, and applies EMA smoothing.
"""
ha_source_id: str = "" # references a HomeAssistantSource
entity_id: str = "" # HA entity ID (e.g. "sensor.temperature")
attribute: str = "" # optional attribute name (empty = use state)
min_ha_value: float = 0.0 # raw HA value mapped to output 0.0
max_ha_value: float = 100.0 # raw HA value mapped to output 1.0
smoothing: float = 0.0 # EMA smoothing factor (0.01.0)
def to_dict(self) -> dict:
d = super().to_dict()
d["ha_source_id"] = self.ha_source_id
d["entity_id"] = self.entity_id
d["attribute"] = self.attribute
d["min_ha_value"] = self.min_ha_value
d["max_ha_value"] = self.max_ha_value
d["smoothing"] = self.smoothing
return d
@classmethod
def from_dict(cls, data: dict) -> "HAEntityValueSource":
common = _parse_common_fields(data)
return cls(
**common,
source_type="ha_entity",
ha_source_id=data.get("ha_source_id") or "",
entity_id=data.get("entity_id") or "",
attribute=data.get("attribute") or "",
min_ha_value=float(data.get("min_ha_value") or 0.0),
max_ha_value=float(
data.get("max_ha_value") if data.get("max_ha_value") is not None else 100.0
),
smoothing=float(data.get("smoothing") or 0.0),
)
@dataclass
class GradientMapValueSource(ValueSource):
"""Color value source that maps a float value source through a gradient.
Takes a float-returning value source (0..1), interpolates the color at
that position in the gradient referenced by gradient_id.
"""
value_source_id: str = "" # references a float-returning ValueSource
gradient_id: str = "" # references a Gradient entity
easing: str = "linear" # linear | step
def to_dict(self) -> dict:
d = super().to_dict()
d["value_source_id"] = self.value_source_id
d["gradient_id"] = self.gradient_id
d["easing"] = self.easing
d["return_type"] = "color"
return d
@classmethod
def from_dict(cls, data: dict) -> "GradientMapValueSource":
common = _parse_common_fields(data)
return cls(
**common,
source_type="gradient_map",
value_source_id=data.get("value_source_id") or "",
gradient_id=data.get("gradient_id") or "",
easing=data.get("easing") or "linear",
)
@dataclass
class CSSExtractValueSource(ValueSource):
"""Color value source that extracts a single color from a color strip source.
Averages the colors in the specified LED range of a color strip source
to produce a single RGB color output.
"""
color_strip_source_id: str = "" # references a ColorStripSource
led_start: int = 0 # start of LED range (0-based)
led_end: int = -1 # end of LED range (-1 = whole strip)
def to_dict(self) -> dict:
d = super().to_dict()
d["color_strip_source_id"] = self.color_strip_source_id
d["led_start"] = self.led_start
d["led_end"] = self.led_end
d["return_type"] = "color"
return d
@classmethod
def from_dict(cls, data: dict) -> "CSSExtractValueSource":
common = _parse_common_fields(data)
return cls(
**common,
source_type="css_extract",
color_strip_source_id=data.get("color_strip_source_id") or "",
led_start=int(data.get("led_start") or 0),
led_end=int(data.get("led_end") if data.get("led_end") is not None else -1),
)
# -- Source type registry --
# Maps source_type string to its subclass for factory dispatch.
_VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = {
@@ -282,4 +476,10 @@ _VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = {
"adaptive_time": AdaptiveValueSource,
"adaptive_scene": AdaptiveValueSource,
"daylight": DaylightValueSource,
"static_color": StaticColorValueSource,
"animated_color": AnimatedColorValueSource,
"adaptive_time_color": AdaptiveTimeColorValueSource,
"ha_entity": HAEntityValueSource,
"gradient_map": GradientMapValueSource,
"css_extract": CSSExtractValueSource,
}
@@ -9,9 +9,15 @@ from wled_controller.storage.database import Database
from wled_controller.storage.utils import resolve_ref
from wled_controller.storage.value_source import (
AdaptiveValueSource,
AdaptiveTimeColorValueSource,
AnimatedColorValueSource,
AnimatedValueSource,
AudioValueSource,
CSSExtractValueSource,
DaylightValueSource,
GradientMapValueSource,
HAEntityValueSource,
StaticColorValueSource,
StaticValueSource,
ValueSource,
)
@@ -56,9 +62,36 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
auto_gain: Optional[bool] = None,
use_real_time: Optional[bool] = None,
latitude: Optional[float] = None,
color: Optional[list] = None,
colors: Optional[list] = None,
easing: Optional[str] = None,
tags: Optional[List[str]] = None,
ha_source_id: Optional[str] = None,
entity_id: Optional[str] = None,
attribute: Optional[str] = None,
min_ha_value: Optional[float] = None,
max_ha_value: Optional[float] = None,
value_source_id: Optional[str] = None,
gradient_id: Optional[str] = None,
color_strip_source_id: Optional[str] = None,
led_start: Optional[int] = None,
led_end: Optional[int] = None,
) -> ValueSource:
if source_type not in ("static", "animated", "audio", "adaptive_time", "adaptive_scene", "daylight"):
_VALID = (
"static",
"animated",
"audio",
"adaptive_time",
"adaptive_scene",
"daylight",
"static_color",
"animated_color",
"adaptive_time_color",
"ha_entity",
"gradient_map",
"css_extract",
)
if source_type not in _VALID:
raise ValueError(f"Invalid source type: {source_type}")
self._check_name_unique(name)
@@ -70,14 +103,24 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
if source_type == "static":
source: ValueSource = StaticValueSource(
id=sid, name=name, source_type="static",
created_at=now, updated_at=now, description=description, tags=common_tags,
id=sid,
name=name,
source_type="static",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
value=value if value is not None else 1.0,
)
elif source_type == "animated":
source = AnimatedValueSource(
id=sid, name=name, source_type="animated",
created_at=now, updated_at=now, description=description, tags=common_tags,
id=sid,
name=name,
source_type="animated",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
waveform=waveform or "sine",
speed=speed if speed is not None else 10.0,
min_value=min_value if min_value is not None else 0.0,
@@ -85,8 +128,13 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
)
elif source_type == "audio":
source = AudioValueSource(
id=sid, name=name, source_type="audio",
created_at=now, updated_at=now, description=description, tags=common_tags,
id=sid,
name=name,
source_type="audio",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
audio_source_id=audio_source_id or "",
mode=mode or "rms",
sensitivity=sensitivity if sensitivity is not None else 1.0,
@@ -100,16 +148,26 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
if len(schedule_data) < 2:
raise ValueError("Time of day schedule requires at least 2 points")
source = AdaptiveValueSource(
id=sid, name=name, source_type="adaptive_time",
created_at=now, updated_at=now, description=description, tags=common_tags,
id=sid,
name=name,
source_type="adaptive_time",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
schedule=schedule_data,
min_value=min_value if min_value is not None else 0.0,
max_value=max_value if max_value is not None else 1.0,
)
elif source_type == "adaptive_scene":
source = AdaptiveValueSource(
id=sid, name=name, source_type="adaptive_scene",
created_at=now, updated_at=now, description=description, tags=common_tags,
id=sid,
name=name,
source_type="adaptive_scene",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
picture_source_id=picture_source_id or "",
scene_behavior=scene_behavior or "complement",
sensitivity=sensitivity if sensitivity is not None else 1.0,
@@ -119,14 +177,111 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
)
elif source_type == "daylight":
source = DaylightValueSource(
id=sid, name=name, source_type="daylight",
created_at=now, updated_at=now, description=description, tags=common_tags,
id=sid,
name=name,
source_type="daylight",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
speed=speed if speed is not None else 1.0,
use_real_time=bool(use_real_time) if use_real_time is not None else False,
latitude=latitude if latitude is not None else 50.0,
min_value=min_value if min_value is not None else 0.0,
max_value=max_value if max_value is not None else 1.0,
)
elif source_type == "static_color":
source = StaticColorValueSource(
id=sid,
name=name,
source_type="static_color",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
color=color if isinstance(color, list) and len(color) == 3 else [255, 255, 255],
)
elif source_type == "animated_color":
source = AnimatedColorValueSource(
id=sid,
name=name,
source_type="animated_color",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
colors=(
colors
if isinstance(colors, list) and len(colors) >= 2
else [[255, 0, 0], [0, 255, 0], [0, 0, 255]]
),
speed=speed if speed is not None else 10.0,
easing=easing or "linear",
)
elif source_type == "adaptive_time_color":
schedule_data = schedule or []
if len(schedule_data) < 2:
raise ValueError("Color schedule requires at least 2 points")
source = AdaptiveTimeColorValueSource(
id=sid,
name=name,
source_type="adaptive_time_color",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
schedule=schedule_data,
)
elif source_type == "ha_entity":
if not ha_source_id:
raise ValueError("HA source ID is required for ha_entity type")
if not entity_id:
raise ValueError("Entity ID is required for ha_entity type")
source = HAEntityValueSource(
id=sid,
name=name,
source_type="ha_entity",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
ha_source_id=ha_source_id,
entity_id=entity_id,
attribute=attribute or "",
min_ha_value=min_ha_value if min_ha_value is not None else 0.0,
max_ha_value=max_ha_value if max_ha_value is not None else 100.0,
smoothing=smoothing if smoothing is not None else 0.0,
)
elif source_type == "gradient_map":
if not value_source_id:
raise ValueError("Value source ID is required for gradient_map type")
source = GradientMapValueSource(
id=sid,
name=name,
source_type="gradient_map",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
value_source_id=value_source_id,
gradient_id=gradient_id or "",
easing=easing or "linear",
)
elif source_type == "css_extract":
if not color_strip_source_id:
raise ValueError("Color strip source ID is required for css_extract type")
source = CSSExtractValueSource(
id=sid,
name=name,
source_type="css_extract",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
color_strip_source_id=color_strip_source_id,
led_start=led_start if led_start is not None else 0,
led_end=led_end if led_end is not None else -1,
)
self._items[sid] = source
self._save_item(sid, source)
@@ -154,7 +309,20 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
auto_gain: Optional[bool] = None,
use_real_time: Optional[bool] = None,
latitude: Optional[float] = None,
color: Optional[list] = None,
colors: Optional[list] = None,
easing: Optional[str] = None,
tags: Optional[List[str]] = None,
ha_source_id: Optional[str] = None,
entity_id: Optional[str] = None,
attribute: Optional[str] = None,
min_ha_value: Optional[float] = None,
max_ha_value: Optional[float] = None,
value_source_id: Optional[str] = None,
gradient_id: Optional[str] = None,
color_strip_source_id: Optional[str] = None,
led_start: Optional[int] = None,
led_end: Optional[int] = None,
) -> ValueSource:
source = self.get(source_id)
@@ -222,6 +390,50 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
source.min_value = min_value
if max_value is not None:
source.max_value = max_value
elif isinstance(source, StaticColorValueSource):
if color is not None and isinstance(color, list) and len(color) == 3:
source.color = color
elif isinstance(source, AnimatedColorValueSource):
if colors is not None and isinstance(colors, list):
source.colors = colors
if speed is not None:
source.speed = speed
if easing is not None:
source.easing = easing
elif isinstance(source, AdaptiveTimeColorValueSource):
if schedule is not None:
if len(schedule) < 2:
raise ValueError("Color schedule requires at least 2 points")
source.schedule = schedule
elif isinstance(source, HAEntityValueSource):
if ha_source_id is not None:
source.ha_source_id = resolve_ref(ha_source_id, source.ha_source_id)
if entity_id is not None:
source.entity_id = entity_id
if attribute is not None:
source.attribute = attribute
if min_ha_value is not None:
source.min_ha_value = min_ha_value
if max_ha_value is not None:
source.max_ha_value = max_ha_value
if smoothing is not None:
source.smoothing = smoothing
elif isinstance(source, GradientMapValueSource):
if value_source_id is not None:
source.value_source_id = resolve_ref(value_source_id, source.value_source_id)
if gradient_id is not None:
source.gradient_id = resolve_ref(gradient_id, source.gradient_id)
if easing is not None:
source.easing = easing
elif isinstance(source, CSSExtractValueSource):
if color_strip_source_id is not None:
source.color_strip_source_id = resolve_ref(
color_strip_source_id, source.color_strip_source_id
)
if led_start is not None:
source.led_start = led_start
if led_end is not None:
source.led_end = led_end
source.updated_at = datetime.now(timezone.utc)
self._save_item(source_id, source)
@@ -9,6 +9,7 @@
<div class="modal-body">
<input type="hidden" id="calibration-device-id">
<input type="hidden" id="calibration-css-id">
<input type="hidden" id="calibration-css-source-type">
<!-- Device picker shown in CSS calibration mode for edge testing -->
<div id="calibration-css-test-group" class="form-group" style="display:none; margin-bottom: 12px; padding: 0 4px;">
<div class="label-row">
@@ -86,7 +86,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.static_color.hint">The solid color that will be sent to all LEDs on the strip.</small>
<input type="color" id="css-editor-color" value="#ffffff">
<div id="css-editor-color-container"></div>
</div>
</div>
@@ -170,7 +170,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.color.hint">Head color for the meteor effect.</small>
<input type="color" id="css-editor-effect-color" value="#ff5000" oninput="updateEffectPreview()">
<div id="css-editor-effect-color-container"></div>
</div>
<div id="css-editor-effect-intensity-group" class="form-group">
@@ -306,7 +306,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.audio.color.hint">Low-level color for VU meter bar.</small>
<input type="color" id="css-editor-audio-color" value="#00ff00">
<div id="css-editor-audio-color-container"></div>
</div>
<div id="css-editor-audio-color-peak-group" class="form-group" style="display:none">
@@ -315,7 +315,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.audio.color_peak.hint">High-level color at the top of the VU meter bar.</small>
<input type="color" id="css-editor-audio-color-peak" value="#ff0000">
<div id="css-editor-audio-color-peak-container"></div>
</div>
<div id="css-editor-audio-mirror-group" class="form-group" style="display:none">
@@ -339,7 +339,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.api_input.fallback_color.hint">Color to display when no data has been received within the timeout period.</small>
<input type="color" id="css-editor-api-input-fallback-color" value="#000000">
<div id="css-editor-api-input-fallback-color-container"></div>
</div>
<div class="form-group">
@@ -422,7 +422,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.default_color.hint">Color used when the notification has no app-specific color mapping.</small>
<input type="color" id="css-editor-notification-default-color" value="#ffffff">
<div id="css-editor-notification-default-color-container"></div>
</div>
<div class="form-group">
@@ -468,15 +468,13 @@
<div class="form-group">
<div class="label-row">
<label for="css-editor-notification-volume">
<label>
<span data-i18n="color_strip.notification.sound.volume">Volume:</span>
<span id="css-editor-notification-volume-val">100%</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.sound.volume.hint">Global volume for notification sounds (0100%).</small>
<input type="range" id="css-editor-notification-volume" min="0" max="100" step="5" value="100"
oninput="document.getElementById('css-editor-notification-volume-val').textContent = this.value + '%'">
<div id="css-editor-notification-volume-container"></div>
</div>
</div>
</details>
@@ -556,7 +554,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.color.hint">The warm base color of the candle flame. Default is a natural warm amber.</small>
<input type="color" id="css-editor-candlelight-color" value="#ff9329">
<div id="css-editor-candlelight-color-container"></div>
</div>
<div class="form-group">
<div class="label-row">
@@ -60,14 +60,14 @@
<div id="ha-light-editor-transition-container"></div>
</div>
<!-- Brightness Value Source -->
<!-- Brightness -->
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-brightness-vs" data-i18n="targets.brightness_vs">Brightness Source:</label>
<label data-i18n="targets.brightness">Brightness:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<select id="ha-light-editor-brightness-vs">
<option value="">None</option>
</select>
<small class="input-hint" style="display:none" data-i18n="targets.brightness.hint">Output brightness multiplier (01). Can be bound to a value source for dynamic control.</small>
<div id="ha-light-editor-brightness-container"></div>
</div>
<!-- Color Tolerance -->
@@ -36,13 +36,11 @@
<div class="form-group">
<div class="label-row">
<label for="target-editor-brightness-vs" data-i18n="targets.brightness_vs">Brightness Source:</label>
<label data-i18n="targets.brightness">Brightness:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.brightness_vs.hint">Optional value source that dynamically controls brightness each frame (overrides device brightness)</small>
<select id="target-editor-brightness-vs">
<option value="" data-i18n="targets.brightness_vs.none">None (device brightness)</option>
</select>
<small class="input-hint" style="display:none" data-i18n="targets.brightness.hint">Output brightness multiplier (01). Can be bound to a value source for dynamic control.</small>
<div id="target-editor-brightness-container"></div>
</div>
<div class="form-group" id="target-editor-fps-group">
@@ -36,6 +36,12 @@
<option value="adaptive_time" data-i18n="value_source.type.adaptive_time">Adaptive (Time of Day)</option>
<option value="adaptive_scene" data-i18n="value_source.type.adaptive_scene">Adaptive (Scene)</option>
<option value="daylight" data-i18n="value_source.type.daylight">Daylight Cycle</option>
<option value="static_color" data-i18n="value_source.type.static_color">Static Color</option>
<option value="animated_color" data-i18n="value_source.type.animated_color">Animated Color</option>
<option value="adaptive_time_color" data-i18n="value_source.type.adaptive_time_color">Time Color</option>
<option value="ha_entity" data-i18n="value_source.type.ha_entity">HA Entity</option>
<option value="gradient_map" data-i18n="value_source.type.gradient_map">Gradient Map</option>
<option value="css_extract" data-i18n="value_source.type.css_extract">Strip Extract</option>
</select>
</div>
@@ -274,6 +280,181 @@
</div>
</div>
<!-- Static Color fields -->
<div id="value-source-static-color-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label data-i18n="value_source.static_color.color">Color:</label>
</div>
<input type="color" id="value-source-static-color" value="#ffffff">
</div>
</div>
<!-- Animated Color fields -->
<div id="value-source-animated-color-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label data-i18n="value_source.animated_color.colors">Colors:</label>
</div>
<div id="value-source-animated-color-list"></div>
<button type="button" class="btn btn-sm" onclick="addAnimatedColor()">+ Add Color</button>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-animated-color-speed">
<span data-i18n="value_source.animated_color.speed">Speed (cpm):</span>
<span id="value-source-animated-color-speed-val">10.0</span>
</label>
</div>
<input type="range" id="value-source-animated-color-speed" min="0.1" max="120" step="0.1" value="10.0"
oninput="document.getElementById('value-source-animated-color-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-animated-color-easing" data-i18n="value_source.animated_color.easing">Easing:</label>
</div>
<select id="value-source-animated-color-easing">
<option value="linear">Linear</option>
<option value="step">Step</option>
</select>
</div>
</div>
<!-- Adaptive Time Color fields -->
<div id="value-source-adaptive-time-color-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label data-i18n="value_source.adaptive_time_color.schedule">Color Schedule:</label>
</div>
<div id="value-source-color-schedule-list"></div>
<button type="button" class="btn btn-sm" onclick="addColorSchedulePoint()">+ Add Point</button>
</div>
</div>
<!-- HA Entity fields -->
<div id="value-source-ha-entity-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="value-source-ha-source" data-i18n="value_source.ha_source">HA Connection:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.ha_source.hint">Home Assistant connection to read entities from</small>
<select id="value-source-ha-source">
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-entity-id" data-i18n="value_source.entity_id">Entity:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.entity_id.hint">HA entity ID (e.g. sensor.temperature)</small>
<select id="value-source-entity-id">
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-attribute" data-i18n="value_source.attribute">Attribute (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.attribute.hint">Read a specific attribute instead of the entity state</small>
<input type="text" id="value-source-attribute" placeholder="">
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-min-ha-value"><span data-i18n="value_source.min_ha_value">Min HA Value:</span> <span id="value-source-min-ha-value-display">0</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.min_ha_value.hint">Raw HA value that maps to 0% output</small>
<input type="number" id="value-source-min-ha-value" step="any" value="0">
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-max-ha-value"><span data-i18n="value_source.max_ha_value">Max HA Value:</span> <span id="value-source-max-ha-value-display">100</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.max_ha_value.hint">Raw HA value that maps to 100% output</small>
<input type="number" id="value-source-max-ha-value" step="any" value="100">
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-ha-smoothing"><span data-i18n="value_source.smoothing">Smoothing:</span> <span id="value-source-ha-smoothing-display">0</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.smoothing.hint">Temporal smoothing (0 = instant, 1 = very smooth)</small>
<input type="range" id="value-source-ha-smoothing" min="0" max="1" step="0.05" value="0"
oninput="document.getElementById('value-source-ha-smoothing-display').textContent = this.value">
</div>
</div>
<!-- Gradient Map fields -->
<div id="value-source-gradient-map-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="value-source-gradient-input" data-i18n="value_source.input_source">Input Value Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.input_source.hint">Float value source (0-1) to map through the gradient</small>
<select id="value-source-gradient-input">
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-gradient-id" data-i18n="value_source.gradient_stops">Gradient:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.gradient_stops.hint">Select a gradient preset to map through</small>
<select id="value-source-gradient-id">
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-gradient-easing" data-i18n="value_source.easing">Interpolation:</label>
</div>
<select id="value-source-gradient-easing">
<option value="linear">Linear</option>
<option value="step">Step</option>
</select>
</div>
</div>
<!-- CSS Extract fields -->
<div id="value-source-css-extract-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="value-source-css-source" data-i18n="value_source.css_source">Color Strip Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.css_source.hint">Color strip source to extract color from</small>
<select id="value-source-css-source">
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-led-start"><span data-i18n="value_source.led_start">LED Start:</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.led_start.hint">First LED in the range (0-based)</small>
<input type="number" id="value-source-led-start" min="0" step="1" value="0">
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-led-end"><span data-i18n="value_source.led_end">LED End:</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.led_end.hint">Last LED in the range (-1 = whole strip)</small>
<input type="number" id="value-source-led-end" min="-1" step="1" value="-1">
</div>
</div>
<!-- Shared adaptive output range (shown for adaptive and daylight types) -->
<div id="value-source-adaptive-range-section" style="display:none">
<div class="form-group">
+62 -45
View File
@@ -4,19 +4,21 @@ Tests creating, listing, updating, cloning, and deleting color strip sources.
"""
class TestColorStripSourceLifecycle:
"""A user manages color strip sources for LED effects."""
def test_static_and_gradient_crud(self, client):
# 1. Create a static color strip source
resp = client.post("/api/v1/color-strip-sources", json={
"name": "Red Static",
"source_type": "static",
"color": [255, 0, 0],
"led_count": 60,
"tags": ["e2e", "static"],
})
resp = client.post(
"/api/v1/color-strip-sources",
json={
"name": "Red Static",
"source_type": "static",
"color": [255, 0, 0],
"led_count": 60,
"tags": ["e2e", "static"],
},
)
assert resp.status_code == 201, f"Create static failed: {resp.text}"
static = resp.json()
static_id = static["id"]
@@ -25,15 +27,18 @@ class TestColorStripSourceLifecycle:
assert static["color"] == [255, 0, 0]
# 2. Create a gradient color strip source
resp = client.post("/api/v1/color-strip-sources", json={
"name": "Blue-Green Gradient",
"source_type": "gradient",
"stops": [
{"position": 0.0, "color": [0, 0, 255]},
{"position": 1.0, "color": [0, 255, 0]},
],
"led_count": 60,
})
resp = client.post(
"/api/v1/color-strip-sources",
json={
"name": "Blue-Green Gradient",
"source_type": "gradient",
"stops": [
{"position": 0.0, "color": [0, 0, 255]},
{"position": 1.0, "color": [0, 255, 0]},
],
"led_count": 60,
},
)
assert resp.status_code == 201, f"Create gradient failed: {resp.text}"
gradient = resp.json()
gradient_id = gradient["id"]
@@ -53,7 +58,7 @@ class TestColorStripSourceLifecycle:
# 4. Update the static source -- change color
resp = client.put(
f"/api/v1/color-strip-sources/{static_id}",
json={"color": [0, 255, 0]},
json={"source_type": "static", "color": [0, 255, 0]},
)
assert resp.status_code == 200
assert resp.json()["color"] == [0, 255, 0]
@@ -64,12 +69,15 @@ class TestColorStripSourceLifecycle:
assert resp.json()["color"] == [0, 255, 0]
# 6. Clone by creating another source with same data, different name
resp = client.post("/api/v1/color-strip-sources", json={
"name": "Cloned Static",
"source_type": "static",
"color": [0, 255, 0],
"led_count": 60,
})
resp = client.post(
"/api/v1/color-strip-sources",
json={
"name": "Cloned Static",
"source_type": "static",
"color": [0, 255, 0],
"led_count": 60,
},
)
assert resp.status_code == 201
clone_id = resp.json()["id"]
assert clone_id != static_id
@@ -87,17 +95,20 @@ class TestColorStripSourceLifecycle:
def test_update_name(self, client):
"""Renaming a color strip source persists."""
resp = client.post("/api/v1/color-strip-sources", json={
"name": "Original Name",
"source_type": "static",
"color": [100, 100, 100],
"led_count": 10,
})
resp = client.post(
"/api/v1/color-strip-sources",
json={
"name": "Original Name",
"source_type": "static",
"color": [100, 100, 100],
"led_count": 10,
},
)
source_id = resp.json()["id"]
resp = client.put(
f"/api/v1/color-strip-sources/{source_id}",
json={"name": "New Name"},
json={"source_type": "static", "name": "New Name"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "New Name"
@@ -126,12 +137,15 @@ class TestColorStripSourceLifecycle:
def test_color_cycle_source(self, client):
"""Color cycle sources store and return their color list."""
resp = client.post("/api/v1/color-strip-sources", json={
"name": "Rainbow Cycle",
"source_type": "color_cycle",
"colors": [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
"led_count": 30,
})
resp = client.post(
"/api/v1/color-strip-sources",
json={
"name": "Rainbow Cycle",
"source_type": "color_cycle",
"colors": [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
"led_count": 30,
},
)
assert resp.status_code == 201
data = resp.json()
assert data["source_type"] == "color_cycle"
@@ -139,14 +153,17 @@ class TestColorStripSourceLifecycle:
def test_effect_source(self, client):
"""Effect sources store their effect parameters."""
resp = client.post("/api/v1/color-strip-sources", json={
"name": "Fire Effect",
"source_type": "effect",
"effect_type": "fire",
"palette": "fire",
"intensity": 1.5,
"led_count": 60,
})
resp = client.post(
"/api/v1/color-strip-sources",
json={
"name": "Fire Effect",
"source_type": "effect",
"effect_type": "fire",
"palette": "fire",
"intensity": 1.5,
"led_count": 60,
},
)
assert resp.status_code == 201
data = resp.json()
assert data["source_type"] == "effect"
+28 -20
View File
@@ -5,18 +5,20 @@ create device -> create target -> list -> update -> delete target -> cleanup dev
"""
class TestOutputTargetLifecycle:
"""A user wires up an output target to a device."""
def _create_device(self, client) -> str:
"""Helper: create a mock device and return its ID."""
resp = client.post("/api/v1/devices", json={
"name": "Target Test Device",
"url": "mock://target-test",
"device_type": "mock",
"led_count": 60,
})
resp = client.post(
"/api/v1/devices",
json={
"name": "Target Test Device",
"url": "mock://target-test",
"device_type": "mock",
"led_count": 60,
},
)
assert resp.status_code == 201
return resp.json()["id"]
@@ -44,7 +46,7 @@ class TestOutputTargetLifecycle:
assert target["name"] == "E2E Test Target"
assert target["device_id"] == device_id
assert target["target_type"] == "led"
assert target["fps"] == 30
assert target["fps"] == 30.0
assert target["protocol"] == "ddp"
# 3. List targets -- should contain the new target
@@ -62,12 +64,12 @@ class TestOutputTargetLifecycle:
# 5. Update the target -- change name and fps
resp = client.put(
f"/api/v1/output-targets/{target_id}",
json={"name": "Updated Target", "fps": 60},
json={"target_type": "led", "name": "Updated Target", "fps": 60},
)
assert resp.status_code == 200
updated = resp.json()
assert updated["name"] == "Updated Target"
assert updated["fps"] == 60
assert updated["fps"] == 60.0
# 6. Verify update via GET
resp = client.get(f"/api/v1/output-targets/{target_id}")
@@ -90,11 +92,14 @@ class TestOutputTargetLifecycle:
"""Deleting a device that has a target should return 409."""
device_id = self._create_device(client)
resp = client.post("/api/v1/output-targets", json={
"name": "Blocking Target",
"target_type": "led",
"device_id": device_id,
})
resp = client.post(
"/api/v1/output-targets",
json={
"name": "Blocking Target",
"target_type": "led",
"device_id": device_id,
},
)
assert resp.status_code == 201
target_id = resp.json()["id"]
@@ -111,11 +116,14 @@ class TestOutputTargetLifecycle:
def test_create_target_with_invalid_device_returns_422(self, client):
"""Creating a target with a non-existent device_id returns 422."""
resp = client.post("/api/v1/output-targets", json={
"name": "Orphan Target",
"target_type": "led",
"device_id": "nonexistent_device",
})
resp = client.post(
"/api/v1/output-targets",
json={
"name": "Orphan Target",
"target_type": "led",
"device_id": "nonexistent_device",
},
)
assert resp.status_code == 422
def test_get_nonexistent_target_returns_404(self, client):