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 ### Configuration
- [x] WledOutputTarget, HALightOutputTarget, HALightMapping — brightness/transition - `ha_source_id: str` — HA connection entity (EntitySelect picker)
- [x] All 15 CSS source types — smoothing, sensitivity, intensity, scale, speed, etc. - `entity_id: str` — HA entity (EntitySelect picker, populated from `/api/v1/home-assistant/sources/{id}/entities`)
- [x] API schemas + routes updated - `attribute: str` — optional attribute name (text input or dropdown populated from entity attributes)
- [x] output_target_store create/update - `min_ha_value: float` — raw HA value corresponding to output 0.0
- [x] processor_manager add_target / add_ha_light_target - `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 - [ ] **Storage model**`HAEntityValueSource` subclass in `storage/value_source.py`
- [x] HALightTargetProcessor — BindableFloat brightness + transition - Fields: `ha_source_id`, `entity_id`, `attribute`, `min_ha_value`, `max_ha_value`, `smoothing`
- [x] All CSS streams use `bfloat()` to extract static values from BindableFloat properties - Register in `_VALUE_SOURCE_MAP` as `"ha_entity"`
- [x] scene_activator — brightness_changed flag - `to_dict()` / `from_dict()` / `_parse_common_fields()`
- [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
### 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 - [ ] **API schemas**`HAEntityValueSourceCreate`, `HAEntityValueSourceResponse` in `api/schemas/value_sources.py`
- [x] targets.ts, ha-light-targets.ts, color-strips.ts — save/load/display - Add to `ValueSourceCreate` / `ValueSourceResponse` discriminated unions
- [x] Graph connections — value source edges for ALL bindable CSS properties - Fields: `ha_source_id`, `entity_id`, `attribute` (optional), `min_ha_value`, `max_ha_value`, `smoothing`
- [x] Graph layout — edge creation for CSS + target bindable properties
- [x] custom_components/select.py — HA integration backward compat
### Phase 4: BindableScalarWidget - [ ] **API routes** — add `HAEntityValueSource` → response builder in `_RESPONSE_MAP`
- [x] `core/bindable-scalar.ts` — reusable widget (slider + VS picker toggle) - [ ] **Stream**`HAEntityValueStream` in `core/processing/value_stream.py`
- [x] CSS styles (`.bindable-toggle`, `.bindable-slider-row`, `.bindable-vs-row`) - `start()`: acquire HA runtime via `ha_manager.acquire(ha_source_id)`
- [x] All 11 CSS editor sliders converted (smoothing, sensitivity, intensity, scale, speed, wind, temp_influence, timeout) - `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
- [x] HTML templates updated with container divs - `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.""" """Audio source routes: CRUD for audio sources + real-time test WebSocket."""
import asyncio 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 starlette.websockets import WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired from wled_controller.api.auth import AuthRequired
@@ -19,8 +19,16 @@ from wled_controller.api.schemas.audio_sources import (
AudioSourceListResponse, AudioSourceListResponse,
AudioSourceResponse, AudioSourceResponse,
AudioSourceUpdate, 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.audio_source_store import AudioSourceStore
from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -31,31 +39,68 @@ logger = get_logger(__name__)
router = APIRouter() 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: def _to_response(source: AudioSource) -> AudioSourceResponse:
"""Convert an AudioSource to an AudioSourceResponse.""" """Convert an AudioSource dataclass to the matching response schema."""
return AudioSourceResponse( builder = _RESPONSE_MAP.get(type(source))
id=source.id, if builder is None:
name=source.name, # Fallback for unknown types — return as multichannel
source_type=source.source_type, return MultichannelAudioSourceResponse(
device_index=getattr(source, "device_index", None), id=source.id,
is_loopback=getattr(source, "is_loopback", None), name=source.name,
audio_template_id=getattr(source, "audio_template_id", None), description=source.description,
audio_source_id=getattr(source, "audio_source_id", None), tags=source.tags,
channel=getattr(source, "channel", None), created_at=source.created_at,
band=getattr(source, "band", None), updated_at=source.updated_at,
freq_low=getattr(source, "freq_low", None), device_index=getattr(source, "device_index", -1),
freq_high=getattr(source, "freq_high", None), is_loopback=getattr(source, "is_loopback", True),
description=source.description, audio_template_id=getattr(source, "audio_template_id", None),
tags=source.tags, )
created_at=source.created_at, return builder(source)
updated_at=source.updated_at,
)
@router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"]) @router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"])
async def list_audio_sources( async def list_audio_sources(
_auth: AuthRequired, _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), store: AudioSourceStore = Depends(get_audio_source_store),
): ):
"""List all audio sources, optionally filtered by type.""" """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( async def create_audio_source(
data: AudioSourceCreate, data: Annotated[AudioSourceCreate, Body(discriminator="source_type")],
_auth: AuthRequired, _auth: AuthRequired,
store: AudioSourceStore = Depends(get_audio_source_store), store: AudioSourceStore = Depends(get_audio_source_store),
): ):
"""Create a new audio source.""" """Create a new audio source."""
try: try:
fields = data.model_dump(exclude={"source_type", "name", "description", "tags"})
source = store.create_source( source = store.create_source(
name=data.name, name=data.name,
source_type=data.source_type, 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, description=data.description,
audio_template_id=data.audio_template_id,
tags=data.tags, tags=data.tags,
band=data.band, **fields,
freq_low=data.freq_low,
freq_high=data.freq_high,
) )
fire_entity_event("audio_source", "created", source.id) fire_entity_event("audio_source", "created", source.id)
return _to_response(source) return _to_response(source)
@@ -99,7 +143,9 @@ async def create_audio_source(
raise HTTPException(status_code=400, detail=str(e)) 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( async def get_audio_source(
source_id: str, source_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -113,29 +159,19 @@ async def get_audio_source(
raise HTTPException(status_code=404, detail=str(e)) 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( async def update_audio_source(
source_id: str, source_id: str,
data: AudioSourceUpdate, data: Annotated[AudioSourceUpdate, Body(discriminator="source_type")],
_auth: AuthRequired, _auth: AuthRequired,
store: AudioSourceStore = Depends(get_audio_source_store), store: AudioSourceStore = Depends(get_audio_source_store),
): ):
"""Update an existing audio source.""" """Update an existing audio source."""
try: try:
source = store.update_source( fields = data.model_dump(exclude={"source_type"}, exclude_none=True)
source_id=source_id, source = store.update_source(source_id=source_id, **fields)
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,
)
fire_entity_event("audio_source", "updated", source_id) fire_entity_event("audio_source", "updated", source_id)
return _to_response(source) return _to_response(source)
except EntityNotFoundError as e: except EntityNotFoundError as e:
@@ -156,11 +192,13 @@ async def delete_audio_source(
try: try:
# Check if any CSS entities reference this audio source # Check if any CSS entities reference this audio source
from wled_controller.storage.color_strip_source import AudioColorStripSource from wled_controller.storage.color_strip_source import AudioColorStripSource
for css in css_store.get_all_sources(): for css in css_store.get_all_sources():
if isinstance(css, AudioColorStripSource) and getattr(css, "audio_source_id", None) == source_id: if (
raise ValueError( isinstance(css, AudioColorStripSource)
f"Cannot delete: referenced by color strip source '{css.name}'" 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) store.delete_source(source_id)
fire_entity_event("audio_source", "deleted", 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. snapshots as JSON at ~20 Hz.
""" """
from wled_controller.api.auth import verify_ws_token from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") await websocket.close(code=4001, reason="Unauthorized")
return return
@@ -211,6 +250,7 @@ async def test_audio_source_ws(
band_mask = None band_mask = None
if resolved.freq_low is not None and resolved.freq_high is not 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 from wled_controller.core.audio.band_filter import compute_band_mask
band_mask = compute_band_mask(resolved.freq_low, resolved.freq_high) band_mask = compute_band_mask(resolved.freq_low, resolved.freq_high)
# Resolve template → engine_type + config # Resolve template → engine_type + config
@@ -257,15 +297,18 @@ async def test_audio_source_ws(
# Apply band filter if present # Apply band filter if present
if band_mask is not None: if band_mask is not None:
from wled_controller.core.audio.band_filter import apply_band_filter from wled_controller.core.audio.band_filter import apply_band_filter
spectrum, rms = apply_band_filter(spectrum, rms, band_mask) spectrum, rms = apply_band_filter(spectrum, rms, band_mask)
await websocket.send_json({ await websocket.send_json(
"spectrum": spectrum.tolist(), {
"rms": round(rms, 4), "spectrum": spectrum.tolist(),
"peak": round(analysis.peak, 4), "rms": round(rms, 4),
"beat": analysis.beat, "peak": round(analysis.peak, 4),
"beat_intensity": round(analysis.beat_intensity, 4), "beat": analysis.beat,
}) "beat_intensity": round(analysis.beat_intensity, 4),
}
)
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
except WebSocketDisconnect: except WebSocketDisconnect:
@@ -4,9 +4,10 @@ import asyncio
import json as _json import json as _json
import time as _time import time as _time
import uuid as _uuid import uuid as _uuid
from typing import Annotated
import numpy as np 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.auth import AuthRequired
from wled_controller.api.dependencies import ( from wled_controller.api.dependencies import (
@@ -20,13 +21,30 @@ from wled_controller.api.dependencies import (
get_template_store, get_template_store,
) )
from wled_controller.api.schemas.color_strip_sources import ( from wled_controller.api.schemas.color_strip_sources import (
ApiInputCSSResponse,
AudioCSSResponse,
CandlelightCSSResponse,
ColorCycleCSSResponse,
ColorPushRequest, ColorPushRequest,
ColorStop as ColorStopSchema,
ColorStripSourceCreate, ColorStripSourceCreate,
ColorStripSourceListResponse, ColorStripSourceListResponse,
ColorStripSourceResponse, ColorStripSourceResponse,
ColorStripSourceUpdate, ColorStripSourceUpdate,
CompositeCSSResponse,
CSSCalibrationTestRequest, CSSCalibrationTestRequest,
DaylightCSSResponse,
EffectCSSResponse,
GradientCSSResponse,
KeyColorsCSSResponse,
MappedCSSResponse,
NotificationCSSResponse,
NotifyRequest, NotifyRequest,
PictureAdvancedCSSResponse,
PictureCSSResponse,
ProcessedCSSResponse,
StaticCSSResponse,
WeatherCSSResponse,
) )
from wled_controller.api.schemas.devices import ( from wled_controller.api.schemas.devices import (
Calibration as CalibrationSchema, Calibration as CalibrationSchema,
@@ -34,15 +52,27 @@ from wled_controller.api.schemas.devices import (
) )
from wled_controller.core.capture.calibration import ( from wled_controller.core.capture.calibration import (
calibration_from_dict, calibration_from_dict,
calibration_to_dict,
) )
from wled_controller.core.capture.screen_capture import get_available_displays from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.color_strip_source import ( from wled_controller.storage.color_strip_source import (
AdvancedPictureColorStripSource, AdvancedPictureColorStripSource,
ApiInputColorStripSource, ApiInputColorStripSource,
AudioColorStripSource,
CandlelightColorStripSource,
ColorCycleColorStripSource,
CompositeColorStripSource, CompositeColorStripSource,
DaylightColorStripSource,
EffectColorStripSource,
GradientColorStripSource,
KeyColorsColorStripSource,
MappedColorStripSource,
NotificationColorStripSource, NotificationColorStripSource,
PictureColorStripSource, PictureColorStripSource,
ProcessedColorStripSource,
StaticColorStripSource,
WeatherColorStripSource,
) )
from wled_controller.storage import DeviceStore from wled_controller.storage import DeviceStore
from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.color_strip_store import ColorStripStore
@@ -61,50 +91,179 @@ logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse: def _common_response_kwargs(source, overlay_active: bool = False) -> dict:
"""Convert a ColorStripSource to a ColorStripSourceResponse. """Shared response fields from any ColorStripSource."""
return dict(
Uses the source's to_dict() for type-specific fields, then applies id=source.id,
schema conversions for calibration and gradient stops. name=source.name,
""" description=source.description,
from wled_controller.api.schemas.color_strip_sources import ColorStop as ColorStopSchema led_count=getattr(source, "led_count", 0),
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,
overlay_active=overlay_active, overlay_active=overlay_active,
clock_id=source.clock_id,
tags=source.tags,
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_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( def _resolve_display_index(
picture_source_id: str, picture_source_store: PictureSourceStore, depth: int = 0 picture_source_id: str, picture_source_store: PictureSourceStore, depth: int = 0
) -> int: ) -> int:
@@ -150,22 +309,37 @@ def _extract_css_kwargs(data) -> dict:
Converts nested Pydantic models (calibration, stops, layers, zones, Converts nested Pydantic models (calibration, stops, layers, zones,
animation) to plain dicts/lists that the store expects. animation) to plain dicts/lists that the store expects.
""" """
kwargs = data.model_dump( # Exclude nested models that need special conversion
exclude_unset=False, exclude={"calibration", "stops", "layers", "zones", "animation"} exclude_fields = {"source_type"}
) for nested in ("calibration", "stops", "layers", "zones", "animation"):
# Remove fields that don't map to store kwargs if hasattr(data, nested):
kwargs.pop("source_type", None) 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 return kwargs
@@ -176,7 +350,7 @@ def _extract_css_kwargs(data) -> dict:
status_code=201, status_code=201,
) )
async def create_color_strip_source( async def create_color_strip_source(
data: ColorStripSourceCreate, data: Annotated[ColorStripSourceCreate, Body(discriminator="source_type")],
_auth: AuthRequired, _auth: AuthRequired,
store: ColorStripStore = Depends(get_color_strip_store), store: ColorStripStore = Depends(get_color_strip_store),
): ):
@@ -223,7 +397,7 @@ async def get_color_strip_source(
) )
async def update_color_strip_source( async def update_color_strip_source(
source_id: str, source_id: str,
data: ColorStripSourceUpdate, data: Annotated[ColorStripSourceUpdate, Body(discriminator="source_type")],
_auth: AuthRequired, _auth: AuthRequired,
store: ColorStripStore = Depends(get_color_strip_store), store: ColorStripStore = Depends(get_color_strip_store),
manager: ProcessorManager = Depends(get_processor_manager), manager: ProcessorManager = Depends(get_processor_manager),
@@ -1,8 +1,9 @@
"""Output target routes: CRUD endpoints and batch state/metrics queries.""" """Output target routes: CRUD endpoints and batch state/metrics queries."""
import asyncio 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.auth import AuthRequired
from wled_controller.api.dependencies import ( from wled_controller.api.dependencies import (
@@ -12,6 +13,9 @@ from wled_controller.api.dependencies import (
get_processor_manager, get_processor_manager,
) )
from wled_controller.api.schemas.output_targets import ( from wled_controller.api.schemas.output_targets import (
HALightMappingSchema,
HALightOutputTargetResponse,
LedOutputTargetResponse,
OutputTargetCreate, OutputTargetCreate,
OutputTargetListResponse, OutputTargetListResponse,
OutputTargetResponse, OutputTargetResponse,
@@ -25,7 +29,6 @@ from wled_controller.storage.ha_light_output_target import (
HALightMapping, HALightMapping,
HALightOutputTarget, HALightOutputTarget,
) )
from wled_controller.api.schemas.output_targets import HALightMappingSchema
from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from wled_controller.storage.base_store import EntityNotFoundError
@@ -35,58 +38,68 @@ logger = get_logger(__name__)
router = APIRouter() 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: def _target_to_response(target) -> OutputTargetResponse:
"""Convert an OutputTarget to OutputTargetResponse.""" """Convert any OutputTarget to the appropriate typed response."""
if isinstance(target, WledOutputTarget): if isinstance(target, WledOutputTarget):
return OutputTargetResponse( return _led_target_to_response(target)
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,
)
elif isinstance(target, HALightOutputTarget): elif isinstance(target, HALightOutputTarget):
return OutputTargetResponse( return _ha_light_target_to_response(target)
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,
)
else: else:
return OutputTargetResponse( # Fallback for unknown types — use LED response with defaults
return LedOutputTargetResponse(
id=target.id, id=target.id,
name=target.name, name=target.name,
target_type=target.target_type,
description=target.description, description=target.description,
tags=target.tags, tags=target.tags,
created_at=target.created_at, 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 "/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
) )
async def create_target( async def create_target(
data: OutputTargetCreate, data: Annotated[OutputTargetCreate, Body(discriminator="target_type")],
_auth: AuthRequired, _auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store), target_store: OutputTargetStore = Depends(get_output_target_store),
device_store: DeviceStore = Depends(get_device_store), device_store: DeviceStore = Depends(get_device_store),
@@ -110,12 +123,14 @@ async def create_target(
"""Create a new output target.""" """Create a new output target."""
try: try:
# Validate device exists if provided # Validate device exists if provided
if data.device_id: device_id = getattr(data, "device_id", "")
if device_id:
try: try:
device_store.get_device(data.device_id) device_store.get_device(device_id)
except ValueError: 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 = ( ha_mappings = (
[ [
HALightMapping( HALightMapping(
@@ -124,9 +139,9 @@ async def create_target(
led_end=m.led_end, led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0), 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 else None
) )
@@ -134,22 +149,22 @@ async def create_target(
target = target_store.create_target( target = target_store.create_target(
name=data.name, name=data.name,
target_type=data.target_type, target_type=data.target_type,
device_id=data.device_id, device_id=device_id,
color_strip_source_id=data.color_strip_source_id, color_strip_source_id=getattr(data, "color_strip_source_id", ""),
brightness=data.brightness, brightness=getattr(data, "brightness", 1.0),
fps=data.fps, fps=getattr(data, "fps", 30),
keepalive_interval=data.keepalive_interval, keepalive_interval=getattr(data, "keepalive_interval", 1.0),
state_check_interval=data.state_check_interval, state_check_interval=getattr(data, "state_check_interval", 30),
min_brightness_threshold=data.min_brightness_threshold, min_brightness_threshold=getattr(data, "min_brightness_threshold", 0),
adaptive_fps=data.adaptive_fps, adaptive_fps=getattr(data, "adaptive_fps", False),
protocol=data.protocol, protocol=getattr(data, "protocol", "ddp"),
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
ha_source_id=data.ha_source_id, ha_source_id=getattr(data, "ha_source_id", ""),
ha_light_mappings=ha_mappings, ha_light_mappings=ha_mappings,
update_rate=data.update_rate, update_rate=getattr(data, "update_rate", 2.0),
transition=data.transition, transition=getattr(data, "transition", 0.5),
color_tolerance=data.color_tolerance, color_tolerance=getattr(data, "color_tolerance", 5),
) )
# Register in processor manager # Register in processor manager
@@ -223,7 +238,7 @@ async def get_target(
) )
async def update_target( async def update_target(
target_id: str, target_id: str,
data: OutputTargetUpdate, data: Annotated[OutputTargetUpdate, Body(discriminator="target_type")],
_auth: AuthRequired, _auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store), target_store: OutputTargetStore = Depends(get_output_target_store),
device_store: DeviceStore = Depends(get_device_store), device_store: DeviceStore = Depends(get_device_store),
@@ -232,15 +247,17 @@ async def update_target(
"""Update a output target.""" """Update a output target."""
try: try:
# Validate device exists if changing # 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: try:
device_store.get_device(data.device_id) device_store.get_device(device_id)
except ValueError: 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 # Build HA light mappings if provided
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
ha_mappings = None ha_mappings = None
if data.ha_light_mappings is not None: if ha_light_mappings_raw is not None:
ha_mappings = [ ha_mappings = [
HALightMapping( HALightMapping(
entity_id=m.entity_id, entity_id=m.entity_id,
@@ -248,57 +265,68 @@ async def update_target(
led_end=m.led_end, led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0), 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 # Update in store
target = target_store.update_target( target = target_store.update_target(
target_id=target_id, target_id=target_id,
name=data.name, name=data.name,
device_id=data.device_id, device_id=device_id,
color_strip_source_id=data.color_strip_source_id, color_strip_source_id=getattr(data, "color_strip_source_id", None),
brightness=data.brightness, brightness=getattr(data, "brightness", None),
fps=data.fps, fps=getattr(data, "fps", None),
keepalive_interval=data.keepalive_interval, keepalive_interval=getattr(data, "keepalive_interval", None),
state_check_interval=data.state_check_interval, state_check_interval=getattr(data, "state_check_interval", None),
min_brightness_threshold=data.min_brightness_threshold, min_brightness_threshold=getattr(data, "min_brightness_threshold", None),
adaptive_fps=data.adaptive_fps, adaptive_fps=getattr(data, "adaptive_fps", None),
protocol=data.protocol, protocol=getattr(data, "protocol", None),
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
ha_source_id=data.ha_source_id, ha_source_id=getattr(data, "ha_source_id", None),
ha_light_mappings=ha_mappings, ha_light_mappings=ha_mappings,
update_rate=data.update_rate, update_rate=getattr(data, "update_rate", None),
transition=data.transition, transition=getattr(data, "transition", None),
color_tolerance=data.color_tolerance, color_tolerance=getattr(data, "color_tolerance", None),
) )
# Sync processor manager (run in thread — css release/acquire can block) # 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: try:
await asyncio.to_thread( await asyncio.to_thread(
target.sync_with_manager, target.sync_with_manager,
manager, manager,
settings_changed=( settings_changed=(
data.fps is not None fps is not None
or data.keepalive_interval is not None or keepalive_interval is not None
or data.state_check_interval is not None or state_check_interval is not None
or data.min_brightness_threshold is not None or min_brightness_threshold is not None
or data.adaptive_fps is not None or adaptive_fps is not None
or data.update_rate is not None or update_rate is not None
or data.transition is not None or transition is not None
or data.color_tolerance is not None or color_tolerance is not None
or data.ha_light_mappings is not None or ha_light_mappings_raw is not None
or data.brightness is not None or brightness is not None
), ),
css_changed=data.color_strip_source_id is not None, css_changed=color_strip_source_id is not None,
brightness_changed=data.brightness is not None, brightness_changed=brightness is not None,
) )
except ValueError as e: except ValueError as e:
logger.debug("Processor config update skipped for target %s: %s", target_id, e) logger.debug("Processor config update skipped for target %s: %s", target_id, e)
pass pass
# Device change requires async stop -> swap -> start cycle # Device change requires async stop -> swap -> start cycle
if data.device_id is not None: if device_id is not None:
try: try:
await manager.update_target_device(target_id, target.device_id) await manager.update_target_device(target_id, target.device_id)
except ValueError as e: except ValueError as e:
@@ -2,10 +2,11 @@
import asyncio import asyncio
import time import time
from typing import Annotated
import httpx import httpx
import numpy as np 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 fastapi.responses import Response
from wled_controller.api.auth import AuthRequired from wled_controller.api.auth import AuthRequired
@@ -29,6 +30,10 @@ from wled_controller.api.schemas.picture_sources import (
PictureSourceResponse, PictureSourceResponse,
PictureSourceTestRequest, PictureSourceTestRequest,
PictureSourceUpdate, PictureSourceUpdate,
ProcessedPictureSourceResponse,
RawPictureSourceResponse,
StaticImagePictureSourceResponse,
VideoPictureSourceResponse,
) )
from wled_controller.core.capture_engines import EngineRegistry from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool 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.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore 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.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from wled_controller.storage.base_store import EntityNotFoundError
@@ -45,34 +55,67 @@ logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
def _stream_to_response(s) -> PictureSourceResponse: _RESPONSE_MAP = {
"""Convert a PictureSource to its API response.""" ScreenCapturePictureSource: lambda s: RawPictureSourceResponse(
return PictureSourceResponse(
id=s.id, id=s.id,
name=s.name, 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, description=s.description,
tags=s.tags, tags=s.tags,
# Video fields created_at=s.created_at,
video_asset_id=getattr(s, "video_asset_id", None), updated_at=s.updated_at,
loop=getattr(s, "loop", None), display_index=s.display_index,
playback_speed=getattr(s, "playback_speed", None), capture_template_id=s.capture_template_id,
start_time=getattr(s, "start_time", None), target_fps=s.target_fps,
end_time=getattr(s, "end_time", None), ),
resolution_limit=getattr(s, "resolution_limit", None), ProcessedPictureSource: lambda s: ProcessedPictureSourceResponse(
clock_id=getattr(s, "clock_id", None), 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( async def list_picture_sources(
_auth: AuthRequired, _auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store), 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") 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( async def validate_image(
data: ImageValidateRequest, data: ImageValidateRequest,
_auth: AuthRequired, _auth: AuthRequired,
@@ -120,6 +167,7 @@ async def validate_image(
load_image_file, load_image_file,
thumbnail as make_thumbnail, thumbnail as make_thumbnail,
) )
if isinstance(src, bytes): if isinstance(src, bytes):
image = load_image_bytes(src) image = load_image_bytes(src)
else: else:
@@ -131,12 +179,12 @@ async def validate_image(
width, height, preview = await asyncio.to_thread(_process_image, img_bytes) width, height, preview = await asyncio.to_thread(_process_image, img_bytes)
return ImageValidateResponse( return ImageValidateResponse(valid=True, width=width, height=height, preview=preview)
valid=True, width=width, height=height, preview=preview
)
except httpx.HTTPStatusError as e: 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: except httpx.RequestError as e:
return ImageValidateResponse(valid=False, error=f"Request failed: {e}") return ImageValidateResponse(valid=False, error=f"Request failed: {e}")
except Exception as e: except Exception as e:
@@ -166,7 +214,12 @@ async def get_full_image(
img_bytes = path img_bytes = path
def _encode_full(src): 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): if isinstance(src, bytes):
image = load_image_bytes(src) image = load_image_bytes(src)
else: else:
@@ -182,9 +235,14 @@ async def get_full_image(
raise HTTPException(status_code=400, detail=str(e)) 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( async def create_picture_source(
data: PictureSourceCreate, data: Annotated[PictureSourceCreate, Body(discriminator="stream_type")],
_auth: AuthRequired, _auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store), store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_template_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}", detail=f"Postprocessing template not found: {data.postprocessing_template_id}",
) )
fields = data.model_dump(exclude={"stream_type", "name", "description", "tags"})
stream = store.create_stream( stream = store.create_stream(
name=data.name, name=data.name,
stream_type=data.stream_type, 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, description=data.description,
tags=data.tags, tags=data.tags,
# Video fields **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,
) )
fire_entity_event("picture_source", "created", stream.id) fire_entity_event("picture_source", "created", stream.id)
return _stream_to_response(stream) return _stream_to_response(stream)
@@ -245,7 +291,11 @@ async def create_picture_source(
raise HTTPException(status_code=500, detail="Internal server error") 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( async def get_picture_source(
stream_id: str, stream_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -259,35 +309,21 @@ async def get_picture_source(
raise HTTPException(status_code=404, detail=f"Picture source {stream_id} not found") 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( async def update_picture_source(
stream_id: str, stream_id: str,
data: PictureSourceUpdate, data: Annotated[PictureSourceUpdate, Body(discriminator="stream_type")],
_auth: AuthRequired, _auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store), store: PictureSourceStore = Depends(get_picture_source_store),
): ):
"""Update a picture source.""" """Update a picture source."""
try: try:
stream = store.update_stream( fields = data.model_dump(exclude={"stream_type"}, exclude_none=True)
stream_id=stream_id, stream = store.update_stream(stream_id=stream_id, **fields)
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,
)
fire_entity_event("picture_source", "updated", stream_id) fire_entity_event("picture_source", "updated", stream_id)
return _stream_to_response(stream) return _stream_to_response(stream)
except EntityNotFoundError as e: except EntityNotFoundError as e:
@@ -316,7 +352,7 @@ async def delete_picture_source(
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. " 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) store.delete_stream(stream_id)
fire_entity_event("picture_source", "deleted", 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 # Resolve video asset to file path
from wled_controller.api.dependencies import get_asset_store as _get_asset_store from wled_controller.api.dependencies import get_asset_store as _get_asset_store
asset_store = _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: if not video_path:
raise HTTPException(status_code=400, detail="Video asset not found or missing file") 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") 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( async def test_picture_source(
stream_id: str, stream_id: str,
test_request: PictureSourceTestRequest, test_request: PictureSourceTestRequest,
@@ -410,7 +453,11 @@ async def test_picture_source(
from wled_controller.utils.image_codec import load_image_file from wled_controller.utils.image_codec import load_image_file
asset_store = _get_asset_store() 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: if not image_path:
raise HTTPException(status_code=400, detail="Image asset not found or missing file") 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 frame_count = 1
last_frame = screen_capture last_frame = screen_capture
else: 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 end_time = start_time + test_request.capture_duration
while time.perf_counter() < end_time: while time.perf_counter() < end_time:
capture_start = time.perf_counter() capture_start = time.perf_counter()
@@ -482,7 +531,10 @@ async def test_picture_source(
image = last_frame.image image = last_frame.image
# Create thumbnail + encode (CPU-bound — run in thread) # 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"] pp_template_ids = chain["postprocessing_template_ids"]
flat_filters = None flat_filters = None
@@ -491,13 +543,16 @@ async def test_picture_source(
pp_template = pp_store.get_template(pp_template_ids[0]) pp_template = pp_store.get_template(pp_template_ids[0])
flat_filters = pp_store.resolve_filter_instances(pp_template.filters) or None flat_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
except ValueError: 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): def _create_thumbnails_and_encode(img, filters):
thumb = make_thumbnail(img, 640) thumb = make_thumbnail(img, 640)
if filters: if filters:
pool = ImagePool() pool = ImagePool()
def apply_filters(arr): def apply_filters(arr):
for fi in filters: for fi in filters:
f = FilterRegistry.create_instance(fi.filter_id, fi.options) f = FilterRegistry.create_instance(fi.filter_id, fi.options)
@@ -505,6 +560,7 @@ async def test_picture_source(
if result is not None: if result is not None:
arr = result arr = result
return arr return arr
thumb = apply_filters(thumb) thumb = apply_filters(thumb)
img = apply_filters(img) img = apply_filters(img)
@@ -513,8 +569,8 @@ async def test_picture_source(
th, tw = thumb.shape[:2] th, tw = thumb.shape[:2]
return tw, th, thumb_uri, full_uri return tw, th, thumb_uri, full_uri
thumbnail_width, thumbnail_height, thumbnail_data_uri, full_data_uri = await asyncio.to_thread( thumbnail_width, thumbnail_height, thumbnail_data_uri, full_data_uri = (
_create_thumbnails_and_encode, image, flat_filters await asyncio.to_thread(_create_thumbnails_and_encode, image, flat_filters)
) )
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0 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 from wled_controller.api.dependencies import get_asset_store as _get_asset_store2
asset_store = _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: if not video_path:
await websocket.close(code=4004, reason="Video asset not found or missing file") await websocket.close(code=4004, reason="Video asset not found or missing file")
return return
@@ -631,6 +691,7 @@ async def test_picture_source_ws(
def _encode_video_frame(image, pw): def _encode_video_frame(image, pw):
"""Encode numpy RGB image as JPEG base64 data URI.""" """Encode numpy RGB image as JPEG base64 data URI."""
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
if pw: if pw:
image = resize_down(image, pw) image = resize_down(image, pw)
h, w = image.shape[:2] h, w = image.shape[:2]
@@ -639,6 +700,7 @@ async def test_picture_source_ws(
try: try:
await asyncio.get_event_loop().run_in_executor(None, video_stream.start) await asyncio.get_event_loop().run_in_executor(None, video_stream.start)
import time as _time import time as _time
fps = min(raw_stream.target_fps or 30, 30) fps = min(raw_stream.target_fps or 30, 30)
frame_time = 1.0 / fps frame_time = 1.0 / fps
end_at = _time.monotonic() + duration end_at = _time.monotonic() + duration
@@ -650,30 +712,42 @@ async def test_picture_source_ws(
last_frame = frame last_frame = frame
frame_count += 1 frame_count += 1
thumb, w, h = await asyncio.get_event_loop().run_in_executor( 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()) elapsed = duration - (end_at - _time.monotonic())
await websocket.send_json({ await websocket.send_json(
"type": "frame", {
"thumbnail": thumb, "type": "frame",
"width": w, "height": h, "thumbnail": thumb,
"elapsed": round(elapsed, 1), "width": w,
"frame_count": frame_count, "height": h,
}) "elapsed": round(elapsed, 1),
"frame_count": frame_count,
}
)
await asyncio.sleep(frame_time) await asyncio.sleep(frame_time)
# Send final result # Send final result
if last_frame is not None: if last_frame is not None:
full_img, fw, fh = await asyncio.get_event_loop().run_in_executor( 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: except WebSocketDisconnect:
logger.debug("Video source test WebSocket disconnected for %s", stream_id) logger.debug("Video source test WebSocket disconnected for %s", stream_id)
pass pass
@@ -701,7 +775,9 @@ async def test_picture_source_ws(
return return
if capture_template.engine_type not in EngineRegistry.get_available_engines(): 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 return
# Resolve postprocessing filters (if any) # Resolve postprocessing filters (if any)
@@ -731,7 +807,9 @@ async def test_picture_source_ws(
try: try:
await stream_capture_test( await stream_capture_test(
websocket, engine_factory, duration, websocket,
engine_factory,
duration,
pp_filters=pp_filters, pp_filters=pp_filters,
preview_width=preview_width or None, preview_width=preview_width or None,
) )
@@ -1,9 +1,9 @@
"""Value source routes: CRUD for value sources.""" """Value source routes: CRUD for value sources."""
import asyncio 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.auth import AuthRequired
from wled_controller.api.dependencies import ( from wled_controller.api.dependencies import (
@@ -13,12 +13,37 @@ from wled_controller.api.dependencies import (
get_value_source_store, get_value_source_store,
) )
from wled_controller.api.schemas.value_sources import ( from wled_controller.api.schemas.value_sources import (
AdaptiveSceneValueSourceResponse,
AdaptiveTimeColorValueSourceResponse,
AdaptiveTimeValueSourceResponse,
AnimatedColorValueSourceResponse,
AnimatedValueSourceResponse,
AudioValueSourceResponse,
CSSExtractValueSourceResponse,
DaylightValueSourceResponse,
GradientMapValueSourceResponse,
HAEntityValueSourceResponse,
StaticColorValueSourceResponse,
StaticValueSourceResponse,
ValueSourceCreate, ValueSourceCreate,
ValueSourceListResponse, ValueSourceListResponse,
ValueSourceResponse, ValueSourceResponse,
ValueSourceUpdate, 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.value_source_store import ValueSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.core.processing.processor_manager import ProcessorManager
@@ -29,40 +54,178 @@ logger = get_logger(__name__)
router = APIRouter() 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: def _to_response(source: ValueSource) -> ValueSourceResponse:
"""Convert a ValueSource to a ValueSourceResponse.""" """Convert a ValueSource dataclass to the matching response schema."""
d = source.to_dict() # AdaptiveValueSource covers both adaptive_time and adaptive_scene
return ValueSourceResponse( if isinstance(source, AdaptiveValueSource):
id=d["id"], if source.source_type == "adaptive_scene":
name=d["name"], return AdaptiveSceneValueSourceResponse(
source_type=d["source_type"], id=source.id,
value=d.get("value"), name=source.name,
waveform=d.get("waveform"), description=source.description,
speed=d.get("speed"), tags=source.tags,
min_value=d.get("min_value"), created_at=source.created_at,
max_value=d.get("max_value"), updated_at=source.updated_at,
audio_source_id=d.get("audio_source_id"), picture_source_id=source.picture_source_id,
mode=d.get("mode"), scene_behavior=source.scene_behavior,
sensitivity=d.get("sensitivity"), sensitivity=source.sensitivity,
smoothing=d.get("smoothing"), smoothing=source.smoothing,
auto_gain=d.get("auto_gain"), min_value=source.min_value,
schedule=d.get("schedule"), max_value=source.max_value,
picture_source_id=d.get("picture_source_id"), )
scene_behavior=d.get("scene_behavior"), return AdaptiveTimeValueSourceResponse(
use_real_time=d.get("use_real_time"), id=source.id,
latitude=d.get("latitude"), name=source.name,
description=d.get("description"), description=source.description,
tags=d.get("tags", []), tags=source.tags,
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_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"]) @router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"])
async def list_value_sources( async def list_value_sources(
_auth: AuthRequired, _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), store: ValueSourceStore = Depends(get_value_source_store),
): ):
"""List all value sources, optionally filtered by type.""" """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( async def create_value_source(
data: ValueSourceCreate, data: Annotated[ValueSourceCreate, Body(discriminator="source_type")],
_auth: AuthRequired, _auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store), store: ValueSourceStore = Depends(get_value_source_store),
): ):
"""Create a new value source.""" """Create a new value source."""
try: try:
# Extract all fields from the discriminated union body
fields = data.model_dump(exclude={"source_type", "name", "description", "tags"})
source = store.create_source( source = store.create_source(
name=data.name, name=data.name,
source_type=data.source_type, 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, 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, tags=data.tags,
**fields,
) )
fire_entity_event("value_source", "created", source.id) fire_entity_event("value_source", "created", source.id)
return _to_response(source) return _to_response(source)
@@ -113,7 +269,9 @@ async def create_value_source(
raise HTTPException(status_code=400, detail=str(e)) 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( async def get_value_source(
source_id: str, source_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -127,37 +285,21 @@ async def get_value_source(
raise HTTPException(status_code=404, detail=str(e)) 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( async def update_value_source(
source_id: str, source_id: str,
data: ValueSourceUpdate, data: Annotated[ValueSourceUpdate, Body(discriminator="source_type")],
_auth: AuthRequired, _auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store), store: ValueSourceStore = Depends(get_value_source_store),
pm: ProcessorManager = Depends(get_processor_manager), pm: ProcessorManager = Depends(get_processor_manager),
): ):
"""Update an existing value source.""" """Update an existing value source."""
try: try:
source = store.update_source( # Extract all fields, excluding None values and the discriminator
source_id=source_id, fields = data.model_dump(exclude={"source_type"}, exclude_none=True)
name=data.name, source = store.update_source(source_id=source_id, **fields)
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,
)
# Hot-reload running value streams # Hot-reload running value streams
pm.update_value_source(source_id) pm.update_value_source(source_id)
fire_entity_event("value_source", "updated", source_id) fire_entity_event("value_source", "updated", source_id)
@@ -180,12 +322,11 @@ async def delete_value_source(
try: try:
# Check if any targets reference this value source # Check if any targets reference this value source
from wled_controller.storage.wled_output_target import WledOutputTarget from wled_controller.storage.wled_output_target import WledOutputTarget
for target in target_store.get_all_targets(): for target in target_store.get_all_targets():
if isinstance(target, WledOutputTarget): if isinstance(target, WledOutputTarget):
if getattr(target, "brightness_value_source_id", "") == source_id: if getattr(target, "brightness_value_source_id", "") == source_id:
raise ValueError( raise ValueError(f"Cannot delete: referenced by target '{target.name}'")
f"Cannot delete: referenced by target '{target.name}'"
)
store.delete_source(source_id) store.delete_source(source_id)
fire_entity_event("value_source", "deleted", 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. and streams {value: float} JSON to the client.
""" """
from wled_controller.api.auth import verify_ws_token from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") await websocket.close(code=4001, reason="Unauthorized")
return return
@@ -1,67 +1,149 @@
"""Audio source schemas (CRUD).""" """Audio source schemas — discriminated unions per source type."""
from datetime import datetime 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): class _AudioSourceResponseBase(BaseModel):
"""Request to create an audio source.""" """Shared fields for all audio source responses."""
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."""
id: str = Field(description="Source ID") id: str = Field(description="Source ID")
name: str = Field(description="Source name") 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") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update 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): class AudioSourceListResponse(BaseModel):
"""List of audio sources.""" """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 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 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) name: str = Field(description="Target name", min_length=1, max_length=100)
target_type: str = Field(default="led", description="Target type (led, ha_light)") description: Optional[str] = Field(None, description="Optional description", max_length=500)
# LED target fields 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") device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID") color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field( brightness: Optional[BindableFloatInput] = Field(
@@ -71,7 +145,7 @@ class OutputTargetCreate(BaseModel):
) )
min_brightness_threshold: Optional[BindableFloatInput] = Field( min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, 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( adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive" default=False, description="Auto-reduce FPS when device is unresponsive"
@@ -81,32 +155,56 @@ class OutputTargetCreate(BaseModel):
pattern="^(ddp|http)$", pattern="^(ddp|http)$",
description="Send protocol: ddp (UDP) or http (JSON API)", 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( 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( 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( 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( color_tolerance: Optional[BindableFloatInput] = Field(
default=5, default=5, description="RGB delta tolerance (bindable)"
description="RGB delta tolerance (bindable, for ha_light targets)", )
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): OutputTargetCreate = Annotated[
"""Request to update an output target.""" 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) 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") device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)") brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
@@ -126,64 +224,41 @@ class OutputTargetUpdate(BaseModel):
protocol: Optional[str] = Field( protocol: Optional[str] = Field(
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)" 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( 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( 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( 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( color_tolerance: Optional[BindableFloatInput] = Field(
None, description="RGB delta tolerance (bindable, for ha_light targets)" None, description="RGB delta tolerance (bindable)"
)
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)"
) )
min_brightness_threshold: Optional[BindableFloatInput] = Field( 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"
) OutputTargetUpdate = Annotated[
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)") Union[
# HA light target fields Annotated[LedOutputTargetUpdate, Tag("led")],
ha_source_id: str = Field(default="", description="Home Assistant source ID (ha_light)") Annotated[HALightOutputTargetUpdate, Tag("ha_light")],
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field( ],
None, description="LED-to-light mappings (ha_light)" Discriminator("target_type"),
) ]
update_rate: Optional[BindableFloatInput] = Field(
None, description="Service call rate Hz (bindable, ha_light)" # =====================================================================
) # List response & utility schemas
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")
class OutputTargetListResponse(BaseModel): class OutputTargetListResponse(BaseModel):
@@ -1,80 +1,183 @@
"""Picture source schemas.""" """Picture source schemas — discriminated unions per stream type."""
from datetime import datetime 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): class _PictureSourceResponseBase(BaseModel):
"""Request to create a picture source.""" """Shared fields for all picture source responses."""
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."""
id: str = Field(description="Stream ID") id: str = Field(description="Stream ID")
name: str = Field(description="Stream name") name: str = Field(description="Stream name")
stream_type: str = Field(description="Stream type (raw, processed, static_image, or video)") description: Optional[str] = Field(None, description="Stream description")
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")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update 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") video_asset_id: Optional[str] = Field(None, description="Video asset ID")
loop: Optional[bool] = Field(None, description="Loop video playback") loop: bool = Field(True, description="Loop video playback")
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier") playback_speed: float = Field(1.0, description="Playback speed multiplier")
start_time: Optional[float] = Field(None, description="Trim start time in seconds") start_time: Optional[float] = Field(None, description="Trim start time in seconds")
end_time: Optional[float] = Field(None, description="Trim end 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") resolution_limit: Optional[int] = Field(None, description="Max width for decode")
clock_id: Optional[str] = Field(None, description="Sync clock ID") 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): class PictureSourceListResponse(BaseModel):
@@ -84,11 +187,23 @@ class PictureSourceListResponse(BaseModel):
count: int = Field(description="Number of streams") count: int = Field(description="Number of streams")
# =====================================================================
# Test / Validation (unchanged)
# =====================================================================
class PictureSourceTestRequest(BaseModel): class PictureSourceTestRequest(BaseModel):
"""Request to test a picture source.""" """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)") capture_duration: float = Field(
border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels for preview") 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): class ImageValidateRequest(BaseModel):
@@ -1,95 +1,402 @@
"""Value source schemas (CRUD).""" """Value source schemas — discriminated unions per source type."""
from datetime import datetime 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): class _ValueSourceResponseBase(BaseModel):
"""Request to create a value source.""" """Shared fields for all value source responses."""
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."""
id: str = Field(description="Source ID") id: str = Field(description="Source ID")
name: str = Field(description="Source name") 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") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update 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): class ValueSourceListResponse(BaseModel):
"""List of value sources.""" """List of value sources."""
@@ -43,10 +43,9 @@ class ApiInputColorStripStream(ColorStripStream):
self._fps = 30 self._fps = 30
# Parse config # Parse config
fallback = source.fallback_color from wled_controller.storage.bindable import bcolor
self._fallback_color = (
fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0] self._fallback_color = bcolor(source.fallback_color, [0, 0, 0])
)
from wled_controller.storage.bindable import bfloat from wled_controller.storage.bindable import bfloat
self._timeout = max(0.0, bfloat(source.timeout, 5.0)) 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._gradient_id = getattr(source, "gradient_id", None)
self._palette_name = getattr(source, "palette", "rainbow") self._palette_name = getattr(source, "palette", "rainbow")
self._resolve_palette_lut() self._resolve_palette_lut()
color = getattr(source, "color", None) from wled_controller.storage.bindable import bcolor
self._color = color if isinstance(color, list) and len(color) == 3 else [0, 255, 0]
color_peak = getattr(source, "color_peak", None) self._color = bcolor(getattr(source, "color", None), [0, 255, 0])
self._color_peak = ( self._color_peak = bcolor(getattr(source, "color_peak", None), [255, 0, 0])
color_peak if isinstance(color_peak, list) and len(color_peak) == 3 else [255, 0, 0]
)
# Pre-computed float arrays for VU meter (avoid per-frame np.array()) # Pre-computed float arrays for VU meter (avoid per-frame np.array())
self._color_f = np.array(self._color, dtype=np.float32) self._color_f = np.array(self._color, dtype=np.float32)
self._color_peak_f = np.array(self._color_peak, dtype=np.float32) self._color_peak_f = np.array(self._color_peak, dtype=np.float32)
@@ -426,8 +424,8 @@ class AudioColorStripStream(ColorStripStream):
buf[:] = 0 buf[:] = 0
if fill_count > 0: if fill_count > 0:
base = self._color_f base = np.array(self.resolve_color("color", self._color), dtype=np.float32)
peak = self._color_peak_f peak = np.array(self.resolve_color("color_peak", self._color_peak), dtype=np.float32)
t = self._vu_gradient[:fill_count] t = self._vu_gradient[:fill_count]
for ch in range(3): for ch in range(3):
buf[:fill_count, ch] = np.clip(base[ch] + (peak[ch] - base[ch]) * t, 0, 255).astype( 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) self._update_from_source(source)
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
raw_color = getattr(source, "color", None) from wled_controller.storage.bindable import bcolor
self._color = (
raw_color if isinstance(raw_color, list) and len(raw_color) == 3 else [255, 147, 41] self._color = bcolor(getattr(source, "color", None), [255, 147, 41])
)
from wled_controller.storage.bindable import bfloat from wled_controller.storage.bindable import bfloat
self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0) self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0)
@@ -261,7 +260,8 @@ class CandlelightColorStripStream(ColorStripStream):
eff_speed = speed * 0.35 * spd_mul eff_speed = speed * 0.35 * spd_mul
intensity = self.resolve("intensity", self._intensity) intensity = self.resolve("intensity", self._intensity)
num_candles = self._num_candles 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 modulation
wind_strength = self.resolve("wind_strength", self._wind_strength) wind_strength = self.resolve("wind_strength", self._wind_strength)
@@ -25,7 +25,7 @@ from wled_controller.core.capture.calibration import (
create_pixel_mapper, create_pixel_mapper,
) )
from wled_controller.core.capture.screen_capture import extract_border_pixels 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 import get_logger
from wled_controller.utils.timer import high_resolution_timer from wled_controller.utils.timer import high_resolution_timer
@@ -136,6 +136,18 @@ class ColorStripStream(ABC):
pass pass
return static 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): class PictureColorStripStream(ColorStripStream):
"""Color strip stream backed by a LiveStream (picture source). """Color strip stream backed by a LiveStream (picture source).
@@ -507,12 +519,7 @@ class StaticColorStripStream(ColorStripStream):
self._update_from_source(source) self._update_from_source(source)
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
color = ( self._source_color = bcolor(source.color, [255, 255, 255])
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
_lc = getattr(source, "led_count", 0) _lc = getattr(source, "led_count", 0)
self._auto_size = not _lc self._auto_size = not _lc
led_count = _lc if _lc and _lc > 0 else 1 led_count = _lc if _lc and _lc > 0 else 1
@@ -643,7 +650,7 @@ class StaticColorStripStream(ColorStripStream):
if atype == "breathing": if atype == "breathing":
factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) 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[:] = ( buf[:] = (
min(255, int(r * factor)), min(255, int(r * factor)),
min(255, int(g * 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. # 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) # 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: if math.sin(2 * math.pi * speed * t * 2.0) >= 0:
buf[:] = self._source_color buf[:] = self.resolve_color("color", self._source_color)
else: else:
buf[:] = 0 buf[:] = 0
colors = buf colors = buf
elif atype == "sparkle": elif atype == "sparkle":
# Random LEDs flash white while the rest stay the base color # 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) density = min(0.5, 0.1 * speed)
mask = np.random.random(n) < density mask = np.random.random(n) < density
buf[mask] = (255, 255, 255) buf[mask] = (255, 255, 255)
@@ -676,7 +683,7 @@ class StaticColorStripStream(ColorStripStream):
factor = phase / 0.1 factor = phase / 0.1
else: else:
factor = math.exp(-5.0 * (phase - 0.1)) 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[:] = ( buf[:] = (
min(255, int(r * factor)), min(255, int(r * factor)),
min(255, int(g * 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.15 * math.sin(2 * math.pi * speed * t * 7.3)
flicker += 0.10 * (np.random.random() - 0.5) flicker += 0.10 * (np.random.random() - 0.5)
factor = max(0.2, min(1.0, base_factor + flicker)) 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[:] = ( buf[:] = (
min(255, int(r * factor)), min(255, int(r * factor)),
min(255, int(g * factor)), min(255, int(g * factor)),
@@ -701,7 +708,7 @@ class StaticColorStripStream(ColorStripStream):
elif atype == "rainbow_fade": elif atype == "rainbow_fade":
# Shift hue continuously from the base color # 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) 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 # speed=1.0 → one full hue rotation every ~10s
h_shift = (speed * t * 0.1) % 1.0 h_shift = (speed * t * 0.1) % 1.0
@@ -147,7 +147,7 @@ class ColorStripStreamManager:
logger.debug("Sync clock release during stream cleanup: %s", e) logger.debug("Sync clock release during stream cleanup: %s", e)
pass # source may have been deleted already 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 = ( _BINDABLE_PROPS = (
"smoothing", "smoothing",
"sensitivity", "sensitivity",
@@ -160,19 +160,23 @@ class ColorStripStreamManager:
"timeout", "timeout",
"brightness", "brightness",
"duration_ms", "duration_ms",
"color",
"color_peak",
"fallback_color",
"default_color",
) )
def _bind_value_streams( def _bind_value_streams(
self, css_stream: ColorStripStream, source, entry: _ColorStripEntry self, css_stream: ColorStripStream, source, entry: _ColorStripEntry
) -> None: ) -> 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: if not self._value_stream_manager:
return return
from wled_controller.storage.bindable import BindableFloat from wled_controller.storage.bindable import BindableColor, BindableFloat
for prop in self._BINDABLE_PROPS: for prop in self._BINDABLE_PROPS:
bf = getattr(source, prop, None) bf = getattr(source, prop, None)
if isinstance(bf, BindableFloat) and bf.source_id: if isinstance(bf, (BindableFloat, BindableColor)) and bf.source_id:
try: try:
vs = self._value_stream_manager.acquire(bf.source_id) vs = self._value_stream_manager.acquire(bf.source_id)
css_stream.set_value_stream(prop, vs) css_stream.set_value_stream(prop, vs)
@@ -286,8 +286,9 @@ class EffectColorStripStream(ColorStripStream):
) )
self._custom_palette = getattr(source, "custom_palette", None) self._custom_palette = getattr(source, "custom_palette", None)
self._resolve_palette_lut() self._resolve_palette_lut()
color = getattr(source, "color", None) from wled_controller.storage.bindable import bcolor
self._color = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0]
self._color = bcolor(getattr(source, "color", None), [255, 80, 0])
from wled_controller.storage.bindable import bfloat from wled_controller.storage.bindable import bfloat
self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0) self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0)
@@ -490,7 +491,7 @@ class EffectColorStripStream(ColorStripStream):
"""Bright meteor head with exponential-decay trail.""" """Bright meteor head with exponential-decay trail."""
speed = self._effective_speed speed = self._effective_speed
intensity = self.resolve("intensity", self._intensity) intensity = self.resolve("intensity", self._intensity)
color = self._color color = self.resolve_color("color", self._color)
mirror = self._mirror mirror = self._mirror
# Compute position along the strip # Compute position along the strip
@@ -686,7 +687,7 @@ class EffectColorStripStream(ColorStripStream):
"""Multiple comets with curved, pulsing tails.""" """Multiple comets with curved, pulsing tails."""
speed = self._effective_speed speed = self._effective_speed
intensity = self.resolve("intensity", self._intensity) intensity = self.resolve("intensity", self._intensity)
color = self._color color = self.resolve_color("color", self._color)
mirror = self._mirror mirror = self._mirror
indices = self._s_arange indices = self._s_arange
@@ -732,7 +733,7 @@ class EffectColorStripStream(ColorStripStream):
"""Physics-simulated bouncing balls with gravity.""" """Physics-simulated bouncing balls with gravity."""
speed = self._effective_speed speed = self._effective_speed
intensity = self.resolve("intensity", self._intensity) intensity = self.resolve("intensity", self._intensity)
color = self._color color = self.resolve_color("color", self._color)
num_balls = 3 num_balls = 3
# Initialize ball state on first call or LED count change # Initialize ball state on first call or LED count change
@@ -76,7 +76,9 @@ class NotificationColorStripStream(ColorStripStream):
"""Parse config from source dataclass.""" """Parse config from source dataclass."""
self._notification_effect = getattr(source, "notification_effect", "flash") self._notification_effect = getattr(source, "notification_effect", "flash")
self._duration_ms = max(100, int(bfloat(getattr(source, "duration_ms", 1500), 1500))) 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 = { self._app_colors = {
k.lower(): v for k, v in dict(getattr(source, "app_colors", {})).items() 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: elif app_lower and app_lower in self._app_colors:
color = _hex_to_rgb(self._app_colors[app_lower]) color = _hex_to_rgb(self._app_colors[app_lower])
else: 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) # Push event to queue (thread-safe deque.append)
# Priority: 0 = normal, 1 = high (high interrupts current effect) # 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, audio_source_store=deps.audio_source_store,
live_stream_manager=self._live_stream_manager, live_stream_manager=self._live_stream_manager,
audio_template_store=deps.audio_template_store, 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 if deps.value_source_store
else None else None
@@ -24,7 +24,7 @@ import math
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import datetime 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 import numpy as np
@@ -32,6 +32,8 @@ from wled_controller.utils import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from wled_controller.core.audio.audio_capture import AudioCaptureManager 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.core.processing.live_stream_manager import LiveStreamManager
from wled_controller.storage.audio_source_store import AudioSourceStore from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.value_source import ValueSource from wled_controller.storage.value_source import ValueSource
@@ -44,6 +46,7 @@ logger = get_logger(__name__)
# Base class # Base class
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class ValueStream(ABC): class ValueStream(ABC):
"""Abstract base for runtime value streams.""" """Abstract base for runtime value streams."""
@@ -52,6 +55,10 @@ class ValueStream(ABC):
"""Return current scalar value (0.01.0).""" """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: def start(self) -> None:
"""Acquire resources (if any).""" """Acquire resources (if any)."""
@@ -66,6 +73,7 @@ class ValueStream(ABC):
# Static # Static
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class StaticValueStream(ValueStream): class StaticValueStream(ValueStream):
"""Returns a constant float.""" """Returns a constant float."""
@@ -77,6 +85,7 @@ class StaticValueStream(ValueStream):
def update_source(self, source: "ValueSource") -> None: def update_source(self, source: "ValueSource") -> None:
from wled_controller.storage.value_source import StaticValueSource from wled_controller.storage.value_source import StaticValueSource
if isinstance(source, StaticValueSource): if isinstance(source, StaticValueSource):
self._value = max(0.0, min(1.0, source.value)) self._value = max(0.0, min(1.0, source.value))
@@ -133,6 +142,7 @@ class AnimatedValueStream(ValueStream):
def update_source(self, source: "ValueSource") -> None: def update_source(self, source: "ValueSource") -> None:
from wled_controller.storage.value_source import AnimatedValueSource from wled_controller.storage.value_source import AnimatedValueSource
if isinstance(source, AnimatedValueSource): if isinstance(source, AnimatedValueSource):
self._waveform = source.waveform self._waveform = source.waveform
self._speed = source.speed self._speed = source.speed
@@ -144,6 +154,7 @@ class AnimatedValueStream(ValueStream):
# Audio # Audio
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class AudioValueStream(ValueStream): class AudioValueStream(ValueStream):
"""Polls audio analysis for a scalar value. """Polls audio analysis for a scalar value.
@@ -173,8 +184,8 @@ class AudioValueStream(ValueStream):
self._min = min_value self._min = min_value
self._max = max_value self._max = max_value
self._auto_gain = auto_gain self._auto_gain = auto_gain
self._rolling_peak = 0.0 # tracks observed max raw audio value self._rolling_peak = 0.0 # tracks observed max raw audio value
self._rolling_decay = 0.995 # slow decay (~5-10s adaptation) self._rolling_decay = 0.995 # slow decay (~5-10s adaptation)
self._audio_capture_manager = audio_capture_manager self._audio_capture_manager = audio_capture_manager
self._audio_source_store = audio_source_store self._audio_source_store = audio_source_store
self._audio_template_store = audio_template_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_type = tpl.engine_type
self._audio_engine_config = tpl.engine_config self._audio_engine_config = tpl.engine_config
except ValueError as e: 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 pass
except ValueError as e: except ValueError as e:
logger.warning(f"Failed to resolve audio source {self._audio_source_id}: {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: if self._audio_capture_manager is None:
return return
self._audio_stream = self._audio_capture_manager.acquire( 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_type=self._audio_engine_type,
engine_config=self._audio_engine_config, engine_config=self._audio_engine_config,
) )
@@ -229,7 +245,8 @@ class AudioValueStream(ValueStream):
def stop(self) -> None: def stop(self) -> None:
if self._audio_stream is not None and self._audio_capture_manager is not None: if self._audio_stream is not None and self._audio_capture_manager is not None:
self._audio_capture_manager.release( 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, engine_type=self._audio_engine_type,
) )
self._audio_stream = None self._audio_stream = None
@@ -295,6 +312,7 @@ class AudioValueStream(ValueStream):
def update_source(self, source: "ValueSource") -> None: def update_source(self, source: "ValueSource") -> None:
from wled_controller.storage.value_source import AudioValueSource from wled_controller.storage.value_source import AudioValueSource
if not isinstance(source, AudioValueSource): if not isinstance(source, AudioValueSource):
return return
@@ -320,10 +338,13 @@ class AudioValueStream(ValueStream):
self._resolve_audio_source() self._resolve_audio_source()
if self._audio_stream is not None and self._audio_capture_manager is not None: if self._audio_stream is not None and self._audio_capture_manager is not None:
self._audio_capture_manager.release( 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_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_type=self._audio_engine_type,
engine_config=self._audio_engine_config, engine_config=self._audio_engine_config,
) )
@@ -403,7 +424,9 @@ class TimeOfDayValueStream(ValueStream):
elapsed = current - t_left elapsed = current - t_left
else: else:
interval = (_MINUTES_PER_DAY - t_left) + t_right 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 frac = elapsed / interval if interval > 0 else 0.0
raw = v_left + frac * (v_right - v_left) raw = v_left + frac * (v_right - v_left)
@@ -413,6 +436,7 @@ class TimeOfDayValueStream(ValueStream):
def update_source(self, source: "ValueSource") -> None: def update_source(self, source: "ValueSource") -> None:
from wled_controller.storage.value_source import AdaptiveValueSource from wled_controller.storage.value_source import AdaptiveValueSource
if isinstance(source, AdaptiveValueSource) and source.source_type == "adaptive_time": if isinstance(source, AdaptiveValueSource) and source.source_type == "adaptive_time":
self._parse_schedule(source.schedule) self._parse_schedule(source.schedule)
self._min = source.min_value self._min = source.min_value
@@ -423,6 +447,7 @@ class TimeOfDayValueStream(ValueStream):
# Scene # Scene
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class SceneValueStream(ValueStream): class SceneValueStream(ValueStream):
"""Derives brightness from a picture source's average frame luminance. """Derives brightness from a picture source's average frame luminance.
@@ -458,12 +483,8 @@ class SceneValueStream(ValueStream):
def start(self) -> None: def start(self) -> None:
if self._live_stream_manager and self._picture_source_id: if self._live_stream_manager and self._picture_source_id:
try: try:
self._live_stream = self._live_stream_manager.acquire( self._live_stream = self._live_stream_manager.acquire(self._picture_source_id)
self._picture_source_id logger.info(f"SceneValueStream acquired live stream for {self._picture_source_id}")
)
logger.info(
f"SceneValueStream acquired live stream for {self._picture_source_id}"
)
except Exception as e: except Exception as e:
logger.warning(f"SceneValueStream failed to acquire live stream: {e}") logger.warning(f"SceneValueStream failed to acquire live stream: {e}")
self._live_stream = None self._live_stream = None
@@ -493,9 +514,14 @@ class SceneValueStream(ValueStream):
sampled = img[::step_h, ::step_w].astype(np.float32) sampled = img[::step_h, ::step_w].astype(np.float32)
# BT.601 weighted luminance, normalized to [0, 1] # BT.601 weighted luminance, normalized to [0, 1]
luminance = float( luminance = (
(0.299 * sampled[:, :, 0] + 0.587 * sampled[:, :, 1] + 0.114 * sampled[:, :, 2]).mean() float(
) / 255.0 (
0.299 * sampled[:, :, 0] + 0.587 * sampled[:, :, 1] + 0.114 * sampled[:, :, 2]
).mean()
)
/ 255.0
)
# Apply sensitivity # Apply sensitivity
raw = min(1.0, luminance * self._sensitivity) raw = min(1.0, luminance * self._sensitivity)
@@ -517,6 +543,7 @@ class SceneValueStream(ValueStream):
def update_source(self, source: "ValueSource") -> None: def update_source(self, source: "ValueSource") -> None:
from wled_controller.storage.value_source import AdaptiveValueSource from wled_controller.storage.value_source import AdaptiveValueSource
if not isinstance(source, AdaptiveValueSource) or source.source_type != "adaptive_scene": if not isinstance(source, AdaptiveValueSource) or source.source_type != "adaptive_scene":
return return
@@ -533,9 +560,7 @@ class SceneValueStream(ValueStream):
if self._live_stream is not None and self._live_stream_manager: if self._live_stream is not None and self._live_stream_manager:
self._live_stream_manager.release(old_id) self._live_stream_manager.release(old_id)
try: try:
self._live_stream = self._live_stream_manager.acquire( self._live_stream = self._live_stream_manager.acquire(self._picture_source_id)
self._picture_source_id
)
logger.info( logger.info(
f"SceneValueStream swapped live stream: {old_id}" f"SceneValueStream swapped live stream: {old_id}"
f"{self._picture_source_id}" f"{self._picture_source_id}"
@@ -567,6 +592,7 @@ class DaylightValueStream(ValueStream):
max_value: float = 1.0, max_value: float = 1.0,
): ):
from wled_controller.core.processing.daylight_stream import _get_daylight_lut from wled_controller.core.processing.daylight_stream import _get_daylight_lut
self._lut = _get_daylight_lut() self._lut = _get_daylight_lut()
self._speed = speed self._speed = speed
self._use_real_time = use_real_time self._use_real_time = use_real_time
@@ -595,6 +621,7 @@ class DaylightValueStream(ValueStream):
def update_source(self, source: "ValueSource") -> None: def update_source(self, source: "ValueSource") -> None:
from wled_controller.storage.value_source import DaylightValueSource from wled_controller.storage.value_source import DaylightValueSource
if isinstance(source, DaylightValueSource): if isinstance(source, DaylightValueSource):
self._speed = source.speed self._speed = source.speed
self._use_real_time = source.use_real_time self._use_real_time = source.use_real_time
@@ -603,10 +630,500 @@ class DaylightValueStream(ValueStream):
self._max = source.max_value 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 # Manager
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class ValueStreamManager: class ValueStreamManager:
"""Owns running ValueStream instances, shared and ref-counted by vs_id. """Owns running ValueStream instances, shared and ref-counted by vs_id.
@@ -623,14 +1140,20 @@ class ValueStreamManager:
audio_source_store: Optional["AudioSourceStore"] = None, audio_source_store: Optional["AudioSourceStore"] = None,
live_stream_manager: Optional["LiveStreamManager"] = None, live_stream_manager: Optional["LiveStreamManager"] = None,
audio_template_store=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._value_source_store = value_source_store
self._audio_capture_manager = audio_capture_manager self._audio_capture_manager = audio_capture_manager
self._audio_source_store = audio_source_store self._audio_source_store = audio_source_store
self._live_stream_manager = live_stream_manager self._live_stream_manager = live_stream_manager
self._audio_template_store = audio_template_store self._audio_template_store = audio_template_store
self._streams: Dict[str, ValueStream] = {} # vs_id → stream self._ha_manager = ha_manager
self._ref_counts: Dict[str, int] = {} # vs_id → ref count 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: def acquire(self, vs_id: str) -> ValueStream:
"""Get or create a shared ValueStream for the given ValueSource. """Get or create a shared ValueStream for the given ValueSource.
@@ -698,8 +1221,14 @@ class ValueStreamManager:
AdaptiveValueSource, AdaptiveValueSource,
AnimatedValueSource, AnimatedValueSource,
AudioValueSource, AudioValueSource,
CSSExtractValueSource,
DaylightValueSource, DaylightValueSource,
GradientMapValueSource,
HAEntityValueSource,
StaticValueSource, StaticValueSource,
StaticColorValueSource,
AnimatedColorValueSource,
AdaptiveTimeColorValueSource,
) )
if isinstance(source, StaticValueSource): if isinstance(source, StaticValueSource):
@@ -753,5 +1282,47 @@ class ValueStreamManager:
max_value=source.max_value, 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 # Fallback
return StaticValueStream(value=1.0) return StaticValueStream(value=1.0)
@@ -845,6 +845,32 @@ textarea:focus-visible {
pointer-events: none; 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 (command-palette style selector) ─────── */
.entity-palette-overlay { .entity-palette-overlay {
@@ -14,9 +14,16 @@
gap: 6px; gap: 6px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
cursor: pointer;
user-select: none; user-select: none;
} }
.dashboard-section-toggle {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
flex: 1;
min-width: 0;
}
.dashboard-section-chevron { .dashboard-section-chevron {
font-size: 0.6rem; font-size: 0.6rem;
@@ -453,13 +460,12 @@
left: 12px; left: 12px;
font-size: 0.6rem; font-size: 0.6rem;
font-weight: 400; font-weight: 400;
color: var(--text-muted); color: var(--text-secondary);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: calc(100% - 4px); max-width: calc(100% - 4px);
pointer-events: none; pointer-events: none;
opacity: 0.7;
z-index: 1; z-index: 1;
} }
@@ -480,9 +486,12 @@
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
background: var(--hover-bg); background: var(--hover-bg);
padding: 1px 5px; padding: 2px 5px;
border-radius: 3px; border-radius: 3px;
letter-spacing: 0.2px; letter-spacing: 0.2px;
vertical-align: middle;
display: inline-flex;
align-items: center;
} }
.perf-chart-label .color-picker-swatch { .perf-chart-label .color-picker-swatch {
@@ -148,6 +148,8 @@ import {
editValueSource, cloneValueSource, deleteValueSource, onValueSourceTypeChange, editValueSource, cloneValueSource, deleteValueSource, onValueSourceTypeChange,
onDaylightVSRealTimeChange, onDaylightVSRealTimeChange,
addSchedulePoint, addSchedulePoint,
addAnimatedColor, removeAnimatedColor,
addColorSchedulePoint, removeColorSchedulePoint,
testValueSource, closeTestValueSourceModal, testValueSource, closeTestValueSourceModal,
} from './features/value-sources.ts'; } from './features/value-sources.ts';
@@ -468,6 +470,10 @@ Object.assign(window, {
onValueSourceTypeChange, onValueSourceTypeChange,
onDaylightVSRealTimeChange, onDaylightVSRealTimeChange,
addSchedulePoint, addSchedulePoint,
addAnimatedColor,
removeAnimatedColor,
addColorSchedulePoint,
removeColorSchedulePoint,
testValueSource, testValueSource,
closeTestValueSourceModal, closeTestValueSourceModal,
@@ -71,11 +71,14 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P
await new Promise(r => setTimeout(r, 500 * 2 ** attempt)); await new Promise(r => setTimeout(r, 500 * 2 ** attempt));
continue; continue;
} }
// Final attempt failed — show user-facing error // Final attempt failed — show toast only if the connection
const errMsg = (err as Error)?.name === 'AbortError' // overlay isn't already covering the screen
? t('api.error.timeout') if (_serverOnline !== false) {
: t('api.error.network'); const errMsg = (err as Error)?.name === 'AbortError'
showToast(errMsg, 'error'); ? t('api.error.timeout')
: t('api.error.network');
showToast(errMsg, 'error');
}
throw err; 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 // Value sources
{ targetKind: 'value_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/value-sources/{id}', cache: valueSourcesCache }, { 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: '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 // Color strip sources
{ targetKind: 'color_strip_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache }, { 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 }, { targetKind: 'color_strip_source', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
// HA light target transition binding // HA light target transition binding
{ targetKind: 'output_target', field: 'transition.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { 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) ── // ── Nested fields (not drag-editable in V1) ──
{ targetKind: 'color_strip_source', field: 'layer.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true }, { 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]); const vsId = bindableSourceId((s as any)[prop]);
if (vsId) addEdge(vsId, s.id, `${prop}.source_id`); 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 // Output target edges
@@ -287,17 +287,36 @@ export class IconSelect {
* the overlay closes and `onPick(value)` is called. Clicking the backdrop * the overlay closes and `onPick(value)` is called. Clicking the backdrop
* or pressing Escape dismisses without picking. * 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; const showFilter = items.length > 9;
// Build cells function buildCells(cellItems: IconSelectItem[]): string {
const cells = items.map(item => return cellItems.map(item =>
`<div class="icon-select-cell" data-value="${item.value}" data-search="${(item.label + ' ' + (item.desc || '')).toLowerCase()}"> `<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-icon">${item.icon}</span>
<span class="icon-select-cell-label">${item.label}</span> <span class="icon-select-cell-label">${item.label}</span>
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''} ${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
</div>` </div>`
).join(''); ).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 // Create overlay
const overlay = document.createElement('div'); const overlay = document.createElement('div');
@@ -305,26 +324,52 @@ export function showTypePicker({ title, items, onPick }: { title: string; items:
overlay.innerHTML = ` overlay.innerHTML = `
<div class="type-picker-dialog"> <div class="type-picker-dialog">
<div class="type-picker-title">${title}</div> <div class="type-picker-title">${title}</div>
${tabsHtml}
${showFilter ? '<input class="type-picker-filter" type="text" placeholder="Filter…" autocomplete="off">' : ''} ${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>`; </div>`;
document.body.appendChild(overlay); document.body.appendChild(overlay);
const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey); }; 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 // Filter logic
if (showFilter) { if (showFilter) {
const input = overlay.querySelector('.type-picker-filter') as HTMLInputElement; const input = overlay.querySelector('.type-picker-filter') as HTMLInputElement;
const allCells = overlay.querySelectorAll('.icon-select-cell');
input.addEventListener('input', () => { input.addEventListener('input', () => {
const q = input.value.toLowerCase().trim(); const q = input.value.toLowerCase().trim();
allCells.forEach(cell => { grid.querySelectorAll('.icon-select-cell').forEach(cell => {
const el = cell as HTMLElement; const el = cell as HTMLElement;
const match = !q || el.dataset.search!.includes(q); const match = !q || el.dataset.search!.includes(q);
el.classList.toggle('disabled', !match); el.classList.toggle('disabled', !match);
}); });
}); });
// Auto-focus filter after animation (skip on touch devices to avoid keyboard popup)
requestAnimationFrame(() => setTimeout(() => desktopFocus(input), 200)); requestAnimationFrame(() => setTimeout(() => desktopFocus(input), 200));
} }
@@ -339,15 +384,6 @@ export function showTypePicker({ title, items, onPick }: { title: string; items:
}; };
document.addEventListener('keydown', onKey); 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 // Animate in
requestAnimationFrame(() => overlay.classList.add('open')); requestAnimationFrame(() => overlay.classList.add('open'));
} }
@@ -34,6 +34,10 @@ const _valueSourceTypeIcons = {
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music), static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
adaptive_time: _svg(P.clock), adaptive_scene: _svg(P.cloudSun), adaptive_time: _svg(P.clock), adaptive_scene: _svg(P.cloudSun),
daylight: _svg(P.sun), 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 _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume2), band_extract: _svg(P.activity) };
const _deviceTypeIcons = { 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_TRENDING_UP = _svg(P.trendingUp);
export const ICON_ACTIVITY = _svg(P.activity); export const ICON_ACTIVITY = _svg(P.activity);
export const ICON_MOVE_VERTICAL = _svg(P.moveVertical); 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_SUN_DIM = _svg(P.sunDim);
export const ICON_CAMERA = _svg(P.camera); export const ICON_CAMERA = _svg(P.camera);
export const ICON_WRENCH = _svg(P.wrench); export const ICON_WRENCH = _svg(P.wrench);
@@ -296,10 +296,12 @@ export const automationsCacheObj = new DataCache<Automation[]>({
}); });
automationsCacheObj.subscribe(v => { _automationsCache = v; }); automationsCacheObj.subscribe(v => { _automationsCache = v; });
export let _cachedColorStripSources: ColorStripSource[] = [];
export const colorStripSourcesCache = new DataCache<ColorStripSource[]>({ export const colorStripSourcesCache = new DataCache<ColorStripSource[]>({
endpoint: '/color-strip-sources', endpoint: '/color-strip-sources',
extractData: json => json.sources || [], extractData: json => json.sources || [],
}); });
colorStripSourcesCache.subscribe(v => { _cachedColorStripSources = v; });
export const csptCache = new DataCache<ColorStripProcessingTemplate[]>({ export const csptCache = new DataCache<ColorStripProcessingTemplate[]>({
endpoint: '/color-strip-processing-templates', endpoint: '/color-strip-processing-templates',
@@ -1,6 +1,9 @@
/** /**
* Tab indicator large semi-transparent blurred icon on the right side * Tab indicator large semi-transparent blurred icon on the right side
* of the viewport, reflecting the currently active tab. * 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 = { const TAB_SVGS = {
@@ -23,14 +26,21 @@ function _ensureEl() {
return _el; 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) { export function updateTabIndicator(tabName) {
if (tabName === _currentTab) return; if (tabName === _currentTab) return;
_currentTab = tabName; _currentTab = tabName;
const svg = TAB_SVGS[tabName]; const svg = TAB_SVGS[tabName];
if (!svg) return; if (!svg) return;
// Respect the dynamic background toggle — hide when bg-anim is off // Hide when no background effect is active
if (document.documentElement.getAttribute('data-bg-anim') !== 'on') { if (!_isBgActive()) {
const el = _ensureEl(); const el = _ensureEl();
el.classList.remove('tab-indicator-visible'); el.classList.remove('tab-indicator-visible');
return; return;
@@ -48,11 +58,10 @@ export function updateTabIndicator(tabName) {
export function initTabIndicator() { export function initTabIndicator() {
_ensureEl(); _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(() => { new MutationObserver(() => {
const on = document.documentElement.getAttribute('data-bg-anim') === 'on';
const el = _ensureEl(); const el = _ensureEl();
if (!on) { if (!_isBgActive()) {
el.classList.remove('tab-indicator-visible'); el.classList.remove('tab-indicator-visible');
} else if (_currentTab) { } else if (_currentTab) {
// Re-trigger show for the current tab // Re-trigger show for the current tab
@@ -60,7 +69,7 @@ export function initTabIndicator() {
_currentTab = null; _currentTab = null;
updateTabIndicator(prev); 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 // Set initial tab from current active button
const active = document.querySelector('.tab-btn.active') as HTMLElement | null; 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); if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
img.src = ''; img.src = '';
img.style.display = ''; 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'; document.getElementById('lightbox-stats')!.style.display = 'none';
const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null; const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null;
if (spinner) spinner.style.display = 'none'; if (spinner) spinner.style.display = 'none';
@@ -29,6 +29,7 @@ interface MonitorRect {
interface CalibrationState { interface CalibrationState {
cssId: string | null; cssId: string | null;
sourceType: string;
lines: CalibrationLine[]; lines: CalibrationLine[];
monitors: MonitorRect[]; monitors: MonitorRect[];
pictureSources: PictureSource[]; pictureSources: PictureSource[];
@@ -94,6 +95,7 @@ const LINE_THICKNESS_PX = 6;
let _state: CalibrationState = { let _state: CalibrationState = {
cssId: null, cssId: null,
sourceType: 'picture_advanced',
lines: [], lines: [],
monitors: [], monitors: [],
pictureSources: [], pictureSources: [],
@@ -122,6 +124,7 @@ class AdvancedCalibrationModal extends Modal {
onForceClose(): void { onForceClose(): void {
if (_lineSourceEntitySelect) { _lineSourceEntitySelect.destroy(); _lineSourceEntitySelect = null; } if (_lineSourceEntitySelect) { _lineSourceEntitySelect.destroy(); _lineSourceEntitySelect = null; }
_state.cssId = null; _state.cssId = null;
_state.sourceType = 'picture_advanced';
_state.lines = []; _state.lines = [];
_state.totalLedCount = 0; _state.totalLedCount = 0;
_state.selectedLine = -1; _state.selectedLine = -1;
@@ -144,6 +147,7 @@ export async function showAdvancedCalibration(cssId: string): Promise<void> {
const psList = psResp.ok ? ((await psResp.json()).streams || []) : []; const psList = psResp.ok ? ((await psResp.json()).streams || []) : [];
_state.cssId = cssId; _state.cssId = cssId;
_state.sourceType = source.source_type || 'picture_advanced';
_state.pictureSources = psList; _state.pictureSources = psList;
_state.totalLedCount = source.led_count || 0; _state.totalLedCount = source.led_count || 0;
@@ -219,7 +223,7 @@ export async function saveAdvancedCalibration(): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`, { const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ calibration }), body: JSON.stringify({ source_type: _state.sourceType, calibration }),
}); });
if (resp.ok) { if (resp.ok) {
@@ -391,7 +391,7 @@ export function applyBgEffect(id: string): void {
/** Restore saved presets on page load. Called from init. */ /** Restore saved presets on page load. Called from init. */
export function initAppearance(): void { export function initAppearance(): void {
_activeStyleId = localStorage.getItem(LS_STYLE_PRESET) || 'default'; _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) // Apply style preset silently (without toast)
const preset = STYLE_PRESETS.find(p => p.id === _activeStyleId); 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; } if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
const calibration: Calibration = source.calibration || {} as Calibration; 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-device-id') as HTMLInputElement).value = '';
(document.getElementById('calibration-css-id') as HTMLInputElement).value = cssId; (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 // Populate device picker for edge test
const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement; const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement;
@@ -931,9 +932,10 @@ export async function saveCalibration() {
try { try {
let response; let response;
if (cssMode) { if (cssMode) {
const cssSourceType = (document.getElementById('calibration-css-source-type') as HTMLInputElement).value || 'picture';
response = await fetchWithAuth(`/color-strip-sources/${cssId}`, { response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ calibration, led_count: declaredLedCount }), body: JSON.stringify({ source_type: cssSourceType, calibration, led_count: declaredLedCount }),
}); });
} else { } else {
response = await fetchWithAuth(`/devices/${deviceId}/calibration`, { 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 { _cachedAssets, _cachedValueSources, assetsCache } from '../core/state.ts';
import { getBaseOrigin } from './settings.ts'; import { getBaseOrigin } from './settings.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.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>`; 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 _notificationEffectIconSelect: any = null;
let _notificationFilterModeIconSelect: any = null; let _notificationFilterModeIconSelect: any = null;
let _notificationDurationWidget: BindableScalarWidget | null = 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 { function _ensureNotificationDurationWidget(): BindableScalarWidget {
if (!_notificationDurationWidget) { if (!_notificationDurationWidget) {
@@ -54,6 +82,8 @@ function _ensureNotificationDurationWidget(): BindableScalarWidget {
export function destroyNotificationDurationWidget(): void { export function destroyNotificationDurationWidget(): void {
if (_notificationDurationWidget) { _notificationDurationWidget.destroy(); _notificationDurationWidget = null; } 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 } { export function getNotificationDurationValue(): number | { value: number; source_id: string } {
@@ -64,6 +94,22 @@ export function getNotificationDurationSnapshot(): string {
return _notificationDurationWidget ? JSON.stringify(_notificationDurationWidget.getValue()) : '1500'; 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() { export function ensureNotificationEffectIconSelect() {
const sel = document.getElementById('css-editor-notification-effect') as HTMLSelectElement | null; const sel = document.getElementById('css-editor-notification-effect') as HTMLSelectElement | null;
if (!sel) return; 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'; (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash'); if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
_ensureNotificationDurationWidget().setValue(css.duration_ms ?? 1500); _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'; (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = css.app_filter_mode || 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(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'); (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 || ''; if (soundSel) soundSel.value = css.sound_asset_id || '';
ensureNotifSoundEntitySelect(); ensureNotifSoundEntitySelect();
if (_notifSoundEntitySelect && css.sound_asset_id) _notifSoundEntitySelect.setValue(css.sound_asset_id); if (_notifSoundEntitySelect && css.sound_asset_id) _notifSoundEntitySelect.setValue(css.sound_asset_id);
const volPct = Math.round((css.sound_volume ?? 1.0) * 100); _ensureNotificationVolumeWidget().setValue(css.sound_volume ?? 1.0);
(document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = volPct as any;
(document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = `${volPct}%`;
// Unified per-app overrides (merge app_colors + app_sounds) // Unified per-app overrides (merge app_colors + app_sounds)
_notificationAppOverrides = _mergeOverrides(css.app_colors || {}, css.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'; (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash'); if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
_ensureNotificationDurationWidget().setValue(1500); _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'; (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off'); if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off');
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = ''; (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = '';
@@ -418,8 +462,7 @@ export async function resetNotificationState() {
_populateSoundOptions(soundSel); _populateSoundOptions(soundSel);
if (soundSel) soundSel.value = ''; if (soundSel) soundSel.value = '';
ensureNotifSoundEntitySelect(); ensureNotifSoundEntitySelect();
(document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = 100 as any; _ensureNotificationVolumeWidget().setValue(1.0);
(document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = '100%';
// Clear overrides // Clear overrides
_notificationAppOverrides = []; _notificationAppOverrides = [];
@@ -15,7 +15,7 @@ import {
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts'; import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.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 ───────────────────────────────────── */ /* ── Preview config builder ───────────────────────────────────── */
@@ -55,7 +55,7 @@ function _collectPreviewConfig() {
app_filter_list: filterList, app_filter_list: filterList,
app_colors: notificationGetAppColorsDict(), app_colors: notificationGetAppColorsDict(),
sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null, 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(), app_sounds: notificationGetAppSoundsDict(),
}; };
} }
@@ -167,8 +167,11 @@ function _testKeyColorsSource(sourceId: string) {
// Show lightbox with spinner // Show lightbox with spinner
const lightbox = document.getElementById('image-lightbox')!; const lightbox = document.getElementById('image-lightbox')!;
const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null; 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; const img = document.getElementById('lightbox-image') as HTMLImageElement;
img.src = ''; img.src = '';
img.style.display = 'none'; // Hide until first frame arrives
if (spinner) spinner.style.display = ''; if (spinner) spinner.style.display = '';
document.getElementById('lightbox-stats')!.style.display = 'none'; document.getElementById('lightbox-stats')!.style.display = 'none';
lightbox.classList.add('active'); lightbox.classList.add('active');
@@ -18,11 +18,12 @@ import {
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ColorStripSource } from '../types.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 { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts'; import { IconSelect, showTypePicker } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts'; import { BindableScalarWidget } from '../core/bindable-scalar.ts';
import { BindableColorWidget } from '../core/bindable-color.ts';
import { getBaseOrigin } from './settings.ts'; import { getBaseOrigin } from './settings.ts';
import { import {
rgbArrayToHex, hexToRgbArray, rgbArrayToHex, hexToRgbArray,
@@ -42,6 +43,8 @@ import {
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory, testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
loadNotificationState, resetNotificationState, showNotificationEndpoint, loadNotificationState, resetNotificationState, showNotificationEndpoint,
destroyNotificationDurationWidget, getNotificationDurationValue, getNotificationDurationSnapshot, destroyNotificationDurationWidget, getNotificationDurationValue, getNotificationDurationSnapshot,
getNotificationDefaultColorValue, getNotificationDefaultColorSnapshot,
getNotificationVolumeValue, getNotificationVolumeSnapshot,
} from './color-strips-notification.ts'; } from './color-strips-notification.ts';
// Re-export for app.js window global bindings // Re-export for app.js window global bindings
@@ -72,6 +75,12 @@ class CSSEditorModal extends Modal {
if (_candlelightWindWidget) { _candlelightWindWidget.destroy(); _candlelightWindWidget = null; } if (_candlelightWindWidget) { _candlelightWindWidget.destroy(); _candlelightWindWidget = null; }
if (_weatherSpeedWidget) { _weatherSpeedWidget.destroy(); _weatherSpeedWidget = null; } if (_weatherSpeedWidget) { _weatherSpeedWidget.destroy(); _weatherSpeedWidget = null; }
if (_weatherTempInfluenceWidget) { _weatherTempInfluenceWidget.destroy(); _weatherTempInfluenceWidget = 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(); destroyNotificationDurationWidget();
if (_kcPictureSourceEntitySelect) { _kcPictureSourceEntitySelect.destroy(); _kcPictureSourceEntitySelect = null; } if (_kcPictureSourceEntitySelect) { _kcPictureSourceEntitySelect.destroy(); _kcPictureSourceEntitySelect = null; }
if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = 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, picture_source: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
interpolation: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value, interpolation: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
smoothing: _smoothingWidget ? JSON.stringify(_smoothingWidget.getValue()) : '0.3', 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, led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]', gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
animation_type: (document.getElementById('css-editor-animation-type') as HTMLInputElement).value, animation_type: (document.getElementById('css-editor-animation-type') as HTMLInputElement).value,
cycle_colors: JSON.stringify(_colorCycleColors), cycle_colors: JSON.stringify(_colorCycleColors),
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
effect_palette: (document.getElementById('css-editor-effect-palette') 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_intensity: _effectIntensityWidget ? JSON.stringify(_effectIntensityWidget.getValue()) : '1.0',
effect_scale: _effectScaleWidget ? JSON.stringify(_effectScaleWidget.getValue()) : '1.0', effect_scale: _effectScaleWidget ? JSON.stringify(_effectScaleWidget.getValue()) : '1.0',
effect_mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked, 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_sensitivity: _audioSensitivityWidget ? JSON.stringify(_audioSensitivityWidget.getValue()) : '1.0',
audio_smoothing: _audioSmoothingWidget ? JSON.stringify(_audioSmoothingWidget.getValue()) : '0.3', audio_smoothing: _audioSmoothingWidget ? JSON.stringify(_audioSmoothingWidget.getValue()) : '0.3',
audio_palette: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value, audio_palette: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
audio_color: (document.getElementById('css-editor-audio-color') as HTMLInputElement).value, audio_color: _audioColorWidget ? JSON.stringify(_audioColorWidget.getValue()) : '[]',
audio_color_peak: (document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value, audio_color_peak: _audioColorPeakWidget ? JSON.stringify(_audioColorPeakWidget.getValue()) : '[]',
audio_mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked, 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_timeout: _apiInputTimeoutWidget ? JSON.stringify(_apiInputTimeoutWidget.getValue()) : '5.0',
api_input_interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLInputElement).value, 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_os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value, notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
notification_duration: getNotificationDurationSnapshot(), 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_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_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value,
notification_app_overrides: JSON.stringify(notificationGetRawAppOverrides()), 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_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_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
daylight_latitude: (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value, 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_intensity: _candlelightIntensityWidget ? JSON.stringify(_candlelightIntensityWidget.getValue()) : '1.0',
candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value, candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value,
candlelight_speed: _candlelightSpeedWidget ? JSON.stringify(_candlelightSpeedWidget.getValue()) : '1.0', candlelight_speed: _candlelightSpeedWidget ? JSON.stringify(_candlelightSpeedWidget.getValue()) : '1.0',
@@ -152,6 +161,14 @@ let _candlelightWindWidget: BindableScalarWidget | null = null;
let _weatherSpeedWidget: BindableScalarWidget | null = null; let _weatherSpeedWidget: BindableScalarWidget | null = null;
let _weatherTempInfluenceWidget: 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 ── // ── EntitySelect instances for CSS editor ──
let _cssPictureSourceEntitySelect: any = null; let _cssPictureSourceEntitySelect: any = null;
let _cssAudioSourceEntitySelect: any = null; let _cssAudioSourceEntitySelect: any = null;
@@ -204,6 +221,7 @@ async function configureKCRegions(sourceId: string): Promise<void> {
if (!showPatternTemplateEditor) return; if (!showPatternTemplateEditor) return;
showPatternTemplateEditor(null, null, { showPatternTemplateEditor(null, null, {
rects: rects.map((r: any) => ({ ...r })), rects: rects.map((r: any) => ({ ...r })),
pictureSourceId: source.picture_source_id || '',
onSave: async (newRects: any[]) => { onSave: async (newRects: any[]) => {
// Save rectangles back to the CSS source // Save rectangles back to the CSS source
try { try {
@@ -286,9 +304,9 @@ const CSS_ALL_SECTION_IDS = [...new Set(Object.values(CSS_SECTION_MAP))];
const CSS_TYPE_SETUP: Record<string, () => void> = { const CSS_TYPE_SETUP: Record<string, () => void> = {
processed: () => _populateProcessedSelectors(), processed: () => _populateProcessedSelectors(),
effect: () => { _ensureEffectTypeIconSelect(); _ensureEffectPaletteIconSelect(); onEffectTypeChange(); }, effect: () => { _ensureEffectTypeIconSelect(); _ensureEffectPaletteEntitySelect(); onEffectTypeChange(); },
audio: () => { _ensureAudioVizIconSelect(); _ensureAudioPaletteIconSelect(); onAudioVizChange(); _loadAudioSources(); }, audio: () => { _ensureAudioVizIconSelect(); _ensureAudioPaletteEntitySelect(); onAudioVizChange(); _loadAudioSources(); },
gradient: () => { _ensureGradientPresetIconSelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); }, gradient: () => { _ensureGradientPresetEntitySelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); },
notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); }, notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); },
candlelight: () => _ensureCandleTypeIconSelect(), candlelight: () => _ensureCandleTypeIconSelect(),
weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); }, weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); },
@@ -491,10 +509,10 @@ function _syncDaylightSpeedVisibility() {
let _animationTypeIconSelect: any = null; let _animationTypeIconSelect: any = null;
let _interpolationIconSelect: any = null; let _interpolationIconSelect: any = null;
let _effectTypeIconSelect: any = null; let _effectTypeIconSelect: any = null;
let _effectPaletteIconSelect: any = null; let _effectPaletteEntitySelect: EntitySelect | null = null;
let _audioPaletteIconSelect: any = null; let _audioPaletteEntitySelect: EntitySelect | null = null;
let _audioVizIconSelect: any = null; let _audioVizIconSelect: any = null;
let _gradientPresetIconSelect: any = null; let _gradientPresetEntitySelect: EntitySelect | null = null;
let _gradientEasingIconSelect: any = null; let _gradientEasingIconSelect: any = null;
let _candleTypeIconSelect: any = null; let _candleTypeIconSelect: any = null;
let _apiInputInterpolationIconSelect: any = null; let _apiInputInterpolationIconSelect: any = null;
@@ -681,6 +699,78 @@ function _ensureWeatherTempInfluenceWidget(): BindableScalarWidget {
return _weatherTempInfluenceWidget; 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() { function _ensureApiInputInterpolationIconSelect() {
const sel = document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement | null; const sel = document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement | null;
if (!sel) return; if (!sel) return;
@@ -714,13 +804,17 @@ function _ensureEffectTypeIconSelect() {
_effectTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 }); _effectTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 });
} }
function _ensureEffectPaletteIconSelect() { function _ensureEffectPaletteEntitySelect() {
const sel = document.getElementById('css-editor-effect-palette') as HTMLSelectElement | null; const sel = document.getElementById('css-editor-effect-palette') as HTMLSelectElement | null;
if (!sel) return; if (!sel) return;
const items = _buildGradientEntityItems(); const items = _buildGradientEntityItems();
_syncSelectOptions(sel, items); _syncSelectOptions(sel, items);
if (_effectPaletteIconSelect) { _effectPaletteIconSelect.updateItems(items); return; } if (_effectPaletteEntitySelect) { _effectPaletteEntitySelect.refresh(); return; }
_effectPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2, searchable: true, searchPlaceholder: t('palette.search') }); _effectPaletteEntitySelect = new EntitySelect({
target: sel,
getItems: _buildGradientEntityItems,
placeholder: t('palette.search'),
});
} }
function _ensureGradientEasingIconSelect() { function _ensureGradientEasingIconSelect() {
@@ -749,13 +843,17 @@ function _ensureCandleTypeIconSelect() {
_candleTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 }); _candleTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 });
} }
function _ensureAudioPaletteIconSelect() { function _ensureAudioPaletteEntitySelect() {
const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null; const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null;
if (!sel) return; if (!sel) return;
const items = _buildGradientEntityItems(); const items = _buildGradientEntityItems();
_syncSelectOptions(sel, items); _syncSelectOptions(sel, items);
if (_audioPaletteIconSelect) { _audioPaletteIconSelect.updateItems(items); return; } if (_audioPaletteEntitySelect) { _audioPaletteEntitySelect.refresh(); return; }
_audioPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2, searchable: true, searchPlaceholder: t('palette.search') }); _audioPaletteEntitySelect = new EntitySelect({
target: sel,
getItems: _buildGradientEntityItems,
placeholder: t('palette.search'),
});
} }
function _ensureAudioVizIconSelect() { 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; const sel = document.getElementById('css-editor-gradient-preset') as HTMLSelectElement | null;
if (!sel) return; if (!sel) return;
const items = _buildGradientEntityItems(); const items = _buildGradientEntityItems();
_syncSelectOptions(sel, items); _syncSelectOptions(sel, items);
if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; } if (_gradientPresetEntitySelect) { _gradientPresetEntitySelect.refresh(); return; }
_gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3, searchable: true, searchPlaceholder: t('palette.search') }); _gradientPresetEntitySelect = new EntitySelect({
target: sel,
getItems: _buildGradientEntityItems,
placeholder: t('palette.search'),
});
} }
/** Rebuild the gradient picker after entity changes. */ /** Rebuild the gradient picker after entity changes. */
export function refreshGradientPresetPicker() { export function refreshGradientPresetPicker() {
const items = _buildGradientEntityItems(); // Re-sync select options before refreshing entity selects
if (_gradientPresetIconSelect) _gradientPresetIconSelect.updateItems(items); for (const selId of ['css-editor-gradient-preset', 'css-editor-effect-palette', 'css-editor-audio-palette']) {
if (_effectPaletteIconSelect) _effectPaletteIconSelect.updateItems(items); const sel = document.getElementById(selId) as HTMLSelectElement | null;
if (_audioPaletteIconSelect) _audioPaletteIconSelect.updateItems(items); 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. */ /** 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'; const audioGradientId = css.gradient_id || 'gr_builtin_rainbow';
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId; (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId;
if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue(audioGradientId); if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.setValue(audioGradientId);
(document.getElementById('css-editor-audio-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [0, 255, 0]); _ensureAudioColorWidget().setValue(css.color);
(document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value = rgbArrayToHex(css.color_peak || [255, 0, 0]); _ensureAudioColorPeakWidget().setValue(css.color_peak);
(document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = css.mirror || false; (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = css.mirror || false;
// Set audio source selector // Set audio source selector
@@ -1216,9 +1322,9 @@ function _resetAudioState() {
_ensureAudioSensitivityWidget().setValue(1.0); _ensureAudioSensitivityWidget().setValue(1.0);
_ensureAudioSmoothingWidget().setValue(0.3); _ensureAudioSmoothingWidget().setValue(0.3);
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow'; (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow';
if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue('gr_builtin_rainbow'); if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.setValue('gr_builtin_rainbow');
(document.getElementById('css-editor-audio-color') as HTMLInputElement).value = '#00ff00'; _ensureAudioColorWidget().setValue([0, 255, 0]);
(document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value = '#ff0000'; _ensureAudioColorPeakWidget().setValue([255, 0, 0]);
(document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = false; (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> = { const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
static: (source, { clockBadge, animBadge }) => { static: (source, { clockBadge, animBadge }) => {
const hexColor = rgbArrayToHex(source.color!); const hexColor = rgbArrayToHex(bindableColor(source.color, [255,255,255]));
return ` return `
<span class="stream-card-prop" title="${t('color_strip.static_color')}"> <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()} <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) => { 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 timeoutVal = bindableValue(source.timeout, 5.0).toFixed(1);
const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear'; const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear';
return ` return `
@@ -1334,13 +1440,14 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
notification: (source) => { notification: (source) => {
const effectLabel = t('color_strip.notification.effect.' + (source.notification_effect || 'flash')) || source.notification_effect || 'flash'; const effectLabel = t('color_strip.notification.effect.' + (source.notification_effect || 'flash')) || source.notification_effect || 'flash';
const durationVal = source.duration_ms || 1500; 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; const appCount = source.app_colors ? Object.keys(source.app_colors).length : 0;
return ` 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.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.duration')}">${ICON_TIMER} ${durationVal}ms</span>
<span class="stream-card-prop" title="${t('color_strip.notification.default_color')}"> <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> </span>
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</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 }) => { 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; const numCandles = source.num_candles ?? 3;
return ` return `
<span class="stream-card-prop" title="${t('color_strip.candlelight.color')}"> <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 }> = { const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...args: any[]) => any; getPayload: (name: any) => any }> = {
static: { static: {
load(css) { load(css) {
(document.getElementById('css-editor-color') as HTMLInputElement).value = rgbArrayToHex(css.color); _ensureStaticColorWidget().setValue(css.color);
_loadAnimationState(css.animation); _loadAnimationState(css.animation);
}, },
reset() { reset() {
(document.getElementById('css-editor-color') as HTMLInputElement).value = '#ffffff'; _ensureStaticColorWidget().setValue([255, 255, 255]);
_loadAnimationState(null); _loadAnimationState(null);
}, },
getPayload(name) { getPayload(name) {
return { return {
name, name,
color: hexToRgbArray((document.getElementById('css-editor-color') as HTMLInputElement).value), color: _ensureStaticColorWidget().getValue(),
animation: _getAnimationPayload(), animation: _getAnimationPayload(),
}; };
}, },
@@ -1573,20 +1680,20 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
gradient: { gradient: {
load(css) { load(css) {
const gradientId = css.gradient_id || ''; const gradientId = css.gradient_id || '';
_ensureGradientPresetIconSelect(); _ensureGradientPresetEntitySelect();
(document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = gradientId; (document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = gradientId;
if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue(gradientId); if (_gradientPresetEntitySelect) _gradientPresetEntitySelect.setValue(gradientId);
_loadAnimationState(css.animation); _loadAnimationState(css.animation);
(document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = css.easing || 'linear'; (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = css.easing || 'linear';
if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue(css.easing || 'linear'); if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue(css.easing || 'linear');
}, },
reset() { reset() {
_ensureGradientPresetIconSelect(); _ensureGradientPresetEntitySelect();
// Default to first gradient // Default to first gradient
const gradients = _getGradients(); const gradients = _getGradients();
const defaultId = gradients.length > 0 ? gradients[0].id : ''; const defaultId = gradients.length > 0 ? gradients[0].id : '';
(document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = defaultId; (document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = defaultId;
if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue(defaultId); if (_gradientPresetEntitySelect) _gradientPresetEntitySelect.setValue(defaultId);
_loadAnimationState(null); _loadAnimationState(null);
(document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = 'linear'; (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = 'linear';
if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue('linear'); if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue('linear');
@@ -1612,8 +1719,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
onEffectTypeChange(); onEffectTypeChange();
const gradientId = css.gradient_id || 'gr_builtin_fire'; const gradientId = css.gradient_id || 'gr_builtin_fire';
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = gradientId; (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = gradientId;
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(gradientId); if (_effectPaletteEntitySelect) _effectPaletteEntitySelect.setValue(gradientId);
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 80, 0]); _ensureEffectColorWidget().setValue(css.color);
_ensureEffectIntensityWidget().setValue(css.intensity ?? 1.0); _ensureEffectIntensityWidget().setValue(css.intensity ?? 1.0);
_ensureEffectScaleWidget().setValue(css.scale ?? 1.0); _ensureEffectScaleWidget().setValue(css.scale ?? 1.0);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false; (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() { reset() {
(document.getElementById('css-editor-effect-type') as HTMLInputElement).value = 'fire'; (document.getElementById('css-editor-effect-type') as HTMLInputElement).value = 'fire';
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = 'gr_builtin_fire'; (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = 'gr_builtin_fire';
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue('gr_builtin_fire'); if (_effectPaletteEntitySelect) _effectPaletteEntitySelect.setValue('gr_builtin_fire');
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = '#ff5000'; _ensureEffectColorWidget().setValue([255, 80, 0]);
_ensureEffectIntensityWidget().setValue(1.0); _ensureEffectIntensityWidget().setValue(1.0);
_ensureEffectScaleWidget().setValue(1.0); _ensureEffectScaleWidget().setValue(1.0);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false; (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 // Meteor/comet/bouncing_ball use a color picker
if (['meteor', 'comet', 'bouncing_ball'].includes(payload.effect_type)) { if (['meteor', 'comet', 'bouncing_ball'].includes(payload.effect_type)) {
const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; payload.color = _ensureEffectColorWidget().getValue();
payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
} }
return payload; return payload;
}, },
@@ -1661,8 +1767,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
sensitivity: _ensureAudioSensitivityWidget().getValue(), sensitivity: _ensureAudioSensitivityWidget().getValue(),
smoothing: _ensureAudioSmoothingWidget().getValue(), smoothing: _ensureAudioSmoothingWidget().getValue(),
gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value, gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
color: hexToRgbArray((document.getElementById('css-editor-audio-color') as HTMLInputElement).value), color: _ensureAudioColorWidget().getValue(),
color_peak: hexToRgbArray((document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value), color_peak: _ensureAudioColorPeakWidget().getValue(),
mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked, 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: { api_input: {
load(css) { load(css) {
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value = _ensureApiInputFallbackColorWidget().setValue(css.fallback_color);
rgbArrayToHex(css.fallback_color || [0, 0, 0]);
_ensureApiInputTimeoutWidget().setValue(css.timeout ?? 5.0); _ensureApiInputTimeoutWidget().setValue(css.timeout ?? 5.0);
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = css.interpolation || 'linear'; (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = css.interpolation || 'linear';
_ensureApiInputInterpolationIconSelect(); _ensureApiInputInterpolationIconSelect();
_showApiInputEndpoints(css.id); _showApiInputEndpoints(css.id);
}, },
reset() { reset() {
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value = '#000000'; _ensureApiInputFallbackColorWidget().setValue([0, 0, 0]);
_ensureApiInputTimeoutWidget().setValue(5.0); _ensureApiInputTimeoutWidget().setValue(5.0);
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = 'linear'; (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = 'linear';
_ensureApiInputInterpolationIconSelect(); _ensureApiInputInterpolationIconSelect();
_showApiInputEndpoints(null); _showApiInputEndpoints(null);
}, },
getPayload(name) { getPayload(name) {
const fbHex = (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value;
return { return {
name, name,
fallback_color: hexToRgbArray(fbHex), fallback_color: _ensureApiInputFallbackColorWidget().getValue(),
timeout: _ensureApiInputTimeoutWidget().getValue(), timeout: _ensureApiInputTimeoutWidget().getValue(),
interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value, 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, os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value, notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
duration_ms: getNotificationDurationValue(), 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_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
app_filter_list: filterList, app_filter_list: filterList,
app_colors: notificationGetAppColorsDict(), app_colors: notificationGetAppColorsDict(),
sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null, 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(), app_sounds: notificationGetAppSoundsDict(),
}; };
}, },
@@ -1788,7 +1892,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
}, },
candlelight: { candlelight: {
load(css) { 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); _ensureCandlelightIntensityWidget().setValue(css.intensity ?? 1.0);
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = css.num_candles ?? 3; (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = css.num_candles ?? 3;
_ensureCandlelightSpeedWidget().setValue(css.speed ?? 1.0); _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'); if (_candleTypeIconSelect) _candleTypeIconSelect.setValue(css.candle_type || 'default');
}, },
reset() { reset() {
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = '#ff9329'; _ensureCandlelightColorWidget().setValue([255, 147, 41]);
_ensureCandlelightIntensityWidget().setValue(1.0); _ensureCandlelightIntensityWidget().setValue(1.0);
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = 3 as any; (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = 3 as any;
_ensureCandlelightSpeedWidget().setValue(1.0); _ensureCandlelightSpeedWidget().setValue(1.0);
@@ -1808,7 +1912,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
getPayload(name) { getPayload(name) {
return { return {
name, name,
color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value), color: _ensureCandlelightColorWidget().getValue(),
intensity: _ensureCandlelightIntensityWidget().getValue(), intensity: _ensureCandlelightIntensityWidget().getValue(),
num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3, num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3,
speed: _ensureCandlelightSpeedWidget().getValue(), speed: _ensureCandlelightSpeedWidget().getValue(),
@@ -2172,7 +2276,7 @@ export async function saveCSSEditor() {
const payload = handler.getPayload(name); const payload = handler.getPayload(name);
if (payload === null) return; // validation error already shown 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 // Attach clock_id for animated types
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather']; 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 collapsed = _getCollapsedSections();
const isCollapsed = !!collapsed[sectionKey]; const isCollapsed = !!collapsed[sectionKey];
const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"'; const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}" onclick="toggleDashboardSection('${sectionKey}')"> return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}">
<span class="dashboard-section-chevron"${chevronStyle}>&#9654;</span> <span class="dashboard-section-toggle" onclick="toggleDashboardSection('${sectionKey}')">
${label} <span class="dashboard-section-chevron"${chevronStyle}>&#9654;</span>
<span class="dashboard-section-count">${count}</span> ${label}
<span class="dashboard-section-count">${count}</span>
</span>
${extraHtml} ${extraHtml}
</div>`; </div>`;
} }
@@ -682,7 +682,7 @@ async function _fetchAllEntities(): Promise<Record<string, any>> {
devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(), devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(),
streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(), streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(),
valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(), valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(),
outputTargetsCache.fetch(), patternTemplatesCache.fetch(), scenePresetsCache.fetch(), outputTargetsCache.fetch(), patternTemplatesCache.fetch().catch(() => []), scenePresetsCache.fetch(),
automationsCacheObj.fetch(), csptCache.fetch(), automationsCacheObj.fetch(), csptCache.fetch(),
fetchWithAuth('/output-targets/batch/states').catch(() => null), 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 { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, formatUptime } from '../core/ui.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 * as P from '../core/icon-paths.ts';
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
import { wrapCard } from '../core/card-colors.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 _haLightTagsInput: TagInput | null = null;
let _haSourceEntitySelect: EntitySelect | null = null; let _haSourceEntitySelect: EntitySelect | null = null;
let _cssSourceEntitySelect: EntitySelect | null = null; let _cssSourceEntitySelect: EntitySelect | null = null;
let _brightnessVsEntitySelect: EntitySelect | null = null; let _brightnessWidget: BindableScalarWidget | null = null;
let _mappingEntitySelects: EntitySelect[] = []; let _mappingEntitySelects: EntitySelect[] = [];
let _editorCssSources: any[] = []; let _editorCssSources: any[] = [];
let _cachedHAEntities: any[] = []; // fetched from selected HA source let _cachedHAEntities: any[] = []; // fetched from selected HA source
@@ -39,7 +39,7 @@ class HALightEditorModal extends Modal {
if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; } if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; }
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; } if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = 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 (_updateRateWidget) { _updateRateWidget.destroy(); _updateRateWidget = null; }
if (_transitionWidget) { _transitionWidget.destroy(); _transitionWidget = null; } if (_transitionWidget) { _transitionWidget.destroy(); _transitionWidget = null; }
if (_colorToleranceWidget) { _colorToleranceWidget.destroy(); _colorToleranceWidget = 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, name: (document.getElementById('ha-light-editor-name') as HTMLInputElement).value,
ha_source: (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).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, 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', update_rate: _updateRateWidget ? JSON.stringify(_updateRateWidget.getValue()) : '2.0',
transition: _transitionWidget ? JSON.stringify(_transitionWidget.getValue()) : '0.5', transition: _transitionWidget ? JSON.stringify(_transitionWidget.getValue()) : '0.5',
color_tolerance: _colorToleranceWidget ? JSON.stringify(_colorToleranceWidget.getValue()) : '5', color_tolerance: _colorToleranceWidget ? JSON.stringify(_colorToleranceWidget.getValue()) : '5',
@@ -215,6 +216,19 @@ export function removeHALightMapping(btn: HTMLElement): void {
// ── Bindable scalar widgets ── // ── 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 { function _ensureUpdateRateWidget(): BindableScalarWidget {
if (!_updateRateWidget) { if (!_updateRateWidget) {
_updateRateWidget = new BindableScalarWidget({ _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 || ''; (document.getElementById('ha-light-editor-name') as HTMLInputElement).value = editData.name || '';
haSelect.value = editData.ha_source_id || ''; haSelect.value = editData.ha_source_id || '';
cssSelect.value = editData.color_strip_source_id || ''; cssSelect.value = editData.color_strip_source_id || '';
_ensureBrightnessWidget().setValue(editData.brightness ?? 1.0);
_ensureUpdateRateWidget().setValue(editData.update_rate ?? 2.0); _ensureUpdateRateWidget().setValue(editData.update_rate ?? 2.0);
_ensureTransitionWidget().setValue(editData.transition ?? 0.5); _ensureTransitionWidget().setValue(editData.transition ?? 0.5);
_ensureColorToleranceWidget().setValue(editData.color_tolerance ?? 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)); mappings.forEach((m: any) => addHALightMapping(m));
} else { } else {
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = ''; (document.getElementById('ha-light-editor-name') as HTMLInputElement).value = '';
_ensureBrightnessWidget().setValue(1.0);
_ensureUpdateRateWidget().setValue(2.0); _ensureUpdateRateWidget().setValue(2.0);
_ensureTransitionWidget().setValue(0.5); _ensureTransitionWidget().setValue(0.5);
_ensureColorToleranceWidget().setValue(5); _ensureColorToleranceWidget().setValue(5);
@@ -375,23 +391,6 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
placeholder: t('palette.search'), 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 // Tags
if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; } if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; }
_haLightTagsInput = new TagInput(document.getElementById('ha-light-tags-container'), { placeholder: t('tags.placeholder') }); _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 // Collect mappings
const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.entity_id); 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 = { const payload: any = {
name, name,
ha_source_id: haSourceId, ha_source_id: haSourceId,
color_strip_source_id: cssSourceId, color_strip_source_id: cssSourceId,
brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0, brightness,
ha_light_mappings: mappings, ha_light_mappings: mappings,
update_rate: updateRate, update_rate: updateRate,
transition, transition,
@@ -446,6 +445,8 @@ export async function saveHALightEditor(): Promise<void> {
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [], tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
}; };
payload.target_type = 'ha_light';
try { try {
let response; let response;
if (targetId) { if (targetId) {
@@ -454,7 +455,6 @@ export async function saveHALightEditor(): Promise<void> {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} else { } else {
payload.target_type = 'ha_light';
response = await fetchWithAuth('/output-targets', { response = await fetchWithAuth('/output-targets', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), 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>` : ''} ${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.listChecks)} ${mappingCount} light${mappingCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop">${_icon(P.clock)} ${target.update_rate ?? 2.0} Hz</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> </div>
${renderTagChips(target.tags || [])} ${renderTagChips(target.tags || [])}
${target.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(target.description)}</div>` : ''} ${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; _inlineCallback = opts?.onSave || null;
try { try {
// Load sources for background capture // 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'; (document.getElementById('pattern-template-error') as HTMLElement).style.display = 'none';
setTimeout(() => desktopFocus(document.getElementById('pattern-template-name')), 100); 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) { } catch (error) {
console.error('Failed to open pattern template editor:', error); console.error('Failed to open pattern template editor:', error);
showToast(t('pattern.error.editor_open_failed'), '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); localStorage.setItem('activeTab', name);
// Update background tab indicator // 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 // Restore scroll position for this tab
requestAnimationFrame(() => window.scrollTo(0, _tabScrollPositions[name] || 0)); requestAnimationFrame(() => window.scrollTo(0, _tabScrollPositions[name] || 0));
@@ -23,7 +23,7 @@ import {
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon, getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW, 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, ICON_WARNING, ICON_PALETTE, ICON_WRENCH, ICON_TEMPLATE, ICON_TRASH,
} from '../core/icons.ts'; } from '../core/icons.ts';
import { EntitySelect } from '../core/entity-palette.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 { CardSection } from '../core/card-sections.ts';
import { TreeNav } from '../core/tree-nav.ts'; import { TreeNav } from '../core/tree-nav.ts';
import { updateSubTabHash, updateTabBadge } from './tabs.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 { bindableSourceId, bindableValue } from '../types.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts'; import { BindableScalarWidget } from '../core/bindable-scalar.ts';
@@ -144,6 +144,7 @@ function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
// --- Editor state --- // --- Editor state ---
let _editorCssSources: any[] = []; // populated when editor opens let _editorCssSources: any[] = []; // populated when editor opens
let _targetTagsInput: TagInput | null = null; let _targetTagsInput: TagInput | null = null;
let _brightnessWidget: BindableScalarWidget | null = null;
let _fpsWidget: BindableScalarWidget | null = null; let _fpsWidget: BindableScalarWidget | null = null;
let _thresholdWidget: 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, device: (document.getElementById('target-editor-device') as HTMLSelectElement).value,
protocol: (document.getElementById('target-editor-protocol') as HTMLSelectElement).value, protocol: (document.getElementById('target-editor-protocol') as HTMLSelectElement).value,
css_source: (document.getElementById('target-editor-css-source') 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', brightness_threshold: _thresholdWidget ? JSON.stringify(_thresholdWidget.getValue()) : '0',
fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30', fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30',
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value, keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
@@ -258,7 +259,6 @@ function _updateBrightnessThresholdVisibility() {
// ── EntitySelect instances for target editor ── // ── EntitySelect instances for target editor ──
let _deviceEntitySelect: EntitySelect | null = null; let _deviceEntitySelect: EntitySelect | null = null;
let _cssEntitySelect: EntitySelect | null = null; let _cssEntitySelect: EntitySelect | null = null;
let _brightnessVsEntitySelect: EntitySelect | null = null;
let _protocolIconSelect: IconSelect | null = null; let _protocolIconSelect: IconSelect | null = null;
function _populateCssDropdown(selectedId = '') { function _populateCssDropdown(selectedId = '') {
@@ -268,15 +268,6 @@ function _populateCssDropdown(selectedId = '') {
).join(''); ).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() { function _ensureTargetEntitySelects() {
// Device // Device
if (_deviceEntitySelect) _deviceEntitySelect.destroy(); if (_deviceEntitySelect) _deviceEntitySelect.destroy();
@@ -304,20 +295,6 @@ function _ensureTargetEntitySelects() {
placeholder: t('palette.search'), 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>`; 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 }); _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 { function _ensureFpsWidget(): BindableScalarWidget {
if (!_fpsWidget) { if (!_fpsWidget) {
_fpsWidget = new BindableScalarWidget({ _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'; (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
_populateCssDropdown(target.color_strip_source_id || ''); _populateCssDropdown(target.color_strip_source_id || '');
_populateBrightnessVsDropdown(bindableSourceId(target.brightness)); _ensureBrightnessWidget().setValue(target.brightness ?? 1.0);
} else if (cloneData) { } else if (cloneData) {
// Cloning — create mode but pre-filled from clone data // Cloning — create mode but pre-filled from clone data
_editorTags = cloneData.tags || []; _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'; (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
_populateCssDropdown(cloneData.color_strip_source_id || ''); _populateCssDropdown(cloneData.color_strip_source_id || '');
_populateBrightnessVsDropdown(bindableSourceId(cloneData.brightness)); _ensureBrightnessWidget().setValue(cloneData.brightness ?? 1.0);
} else { } else {
// Creating new target // Creating new target
(document.getElementById('target-editor-id') as HTMLInputElement).value = ''; (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'; (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
_populateCssDropdown(''); _populateCssDropdown('');
_populateBrightnessVsDropdown(''); _ensureBrightnessWidget().setValue(1.0);
} }
// Entity palette selectors // Entity palette selectors
@@ -454,7 +444,6 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
window._targetAutoName = _autoGenerateTargetName; window._targetAutoName = _autoGenerateTargetName;
deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateSpecificSettingsVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateSpecificSettingsVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
(document.getElementById('target-editor-css-source') as HTMLSelectElement).onchange = () => { _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(); if (!targetId && !cloneData) _autoGenerateTargetName();
// Show/hide conditional fields // Show/hide conditional fields
@@ -492,6 +481,7 @@ export async function closeTargetEditorModal() {
export function forceCloseTargetEditorModal() { export function forceCloseTargetEditorModal() {
if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; } if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; }
if (_brightnessWidget) { _brightnessWidget.destroy(); _brightnessWidget = null; }
if (_fpsWidget) { _fpsWidget.destroy(); _fpsWidget = null; } if (_fpsWidget) { _fpsWidget.destroy(); _fpsWidget = null; }
if (_thresholdWidget) { _thresholdWidget.destroy(); _thresholdWidget = null; } if (_thresholdWidget) { _thresholdWidget.destroy(); _thresholdWidget = null; }
targetEditorModal.forceClose(); targetEditorModal.forceClose();
@@ -511,7 +501,7 @@ export async function saveTargetEditor() {
const fps = _fpsWidget ? _fpsWidget.getValue() : 30; const fps = _fpsWidget ? _fpsWidget.getValue() : 30;
const colorStripSourceId = (document.getElementById('target-editor-css-source') as HTMLSelectElement).value; 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 minBrightnessThreshold = _thresholdWidget ? _thresholdWidget.getValue() : 0;
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked; const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
@@ -521,7 +511,7 @@ export async function saveTargetEditor() {
name, name,
device_id: deviceId, device_id: deviceId,
color_strip_source_id: colorStripSourceId, color_strip_source_id: colorStripSourceId,
brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0, brightness,
min_brightness_threshold: minBrightnessThreshold, min_brightness_threshold: minBrightnessThreshold,
fps, fps,
keepalive_interval: standbyInterval, keepalive_interval: standbyInterval,
@@ -530,6 +520,8 @@ export async function saveTargetEditor() {
tags: _targetTagsInput ? _targetTagsInput.getValue() : [], tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
}; };
payload.target_type = 'led';
try { try {
let response; let response;
if (targetId) { if (targetId) {
@@ -538,7 +530,6 @@ export async function saveTargetEditor() {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} else { } else {
payload.target_type = 'led';
response = await fetchWithAuth('/output-targets', { response = await fetchWithAuth('/output-targets', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -651,7 +642,7 @@ export async function loadTargetsTab() {
// Group by type // Group by type
const ledDevices = devicesWithState; 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'); const haLightTargets = targetsWithState.filter(t => t.target_type === 'ha_light');
// Update tab badge with running target count // Update tab badge with running target count
@@ -969,7 +960,7 @@ function _patchTargetMetrics(target: any) {
if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds); 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 state = target.state || {};
const isProcessing = state.processing || false; 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.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" 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> <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>` : ''} ${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> </div>
${renderTagChips(target.tags)} ${renderTagChips(target.tags)}
@@ -10,22 +10,29 @@
* This module manages the editor modal and API operations. * 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 { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast, showConfirm } from '../core/ui.ts'; import { showToast, showConfirm } from '../core/ui.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { import {
getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon, getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon, getColorStripIcon, getHAEntityIcon,
ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_CLONE, ICON_EDIT, ICON_TEST,
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK, 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_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'; } from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.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 { EntitySelect } from '../core/entity-palette.ts';
import { loadPictureSources } from './streams.ts'; import { loadPictureSources } from './streams.ts';
import { hexToRgbArray, rgbArrayToHex } from './css-gradient-editor.ts';
import type { ValueSource } from '../types.ts'; import type { ValueSource } from '../types.ts';
export { getValueSourceIcon }; export { getValueSourceIcon };
@@ -33,6 +40,13 @@ export { getValueSourceIcon };
// ── EntitySelect instances for value source editor ── // ── EntitySelect instances for value source editor ──
let _vsAudioSourceEntitySelect: EntitySelect | null = null; let _vsAudioSourceEntitySelect: EntitySelect | null = null;
let _vsPictureSourceEntitySelect: 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; let _vsTagsInput: TagInput | null = null;
class ValueSourceModal extends Modal { class ValueSourceModal extends Modal {
@@ -40,6 +54,13 @@ class ValueSourceModal extends Modal {
onForceClose() { onForceClose() {
if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; } 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() { snapshotValues() {
@@ -68,6 +89,11 @@ class ValueSourceModal extends Modal {
daylightSpeed: (document.getElementById('value-source-daylight-speed') as HTMLInputElement).value, daylightSpeed: (document.getElementById('value-source-daylight-speed') as HTMLInputElement).value,
daylightRealTime: (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked, daylightRealTime: (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked,
daylightLatitude: (document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value, 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() : []), tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []),
}; };
} }
@@ -101,10 +127,20 @@ function _autoGenerateVSName() {
/* ── Icon-grid type selector ──────────────────────────────────── */ /* ── 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() { let _vsTypeFilter: 'all' | 'float' | 'color' = 'all';
return VS_TYPE_KEYS.map(key => ({
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, value: key,
icon: getValueSourceIcon(key), icon: getValueSourceIcon(key),
label: t(`value_source.type.${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 _vsTypeIconSelect: IconSelect | null = null;
let _waveformIconSelect: IconSelect | null = null; let _waveformIconSelect: IconSelect | null = null;
let _vsColorEasingIconSelect: IconSelect | null = null;
const _WAVEFORM_SVG = { 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>', 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); _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 ──────────────────────────────────── */ /* ── Waveform canvas preview ──────────────────────────────────── */
/** /**
@@ -251,10 +301,20 @@ function _ensureVSTypeIconSelect() {
export async function showValueSourceModal(editData: any, presetType: any = null) { export async function showValueSourceModal(editData: any, presetType: any = null) {
// When creating new: show type picker first, then re-enter with presetType // When creating new: show type picker first, then re-enter with presetType
if (!editData && !presetType) { if (!editData && !presetType) {
_vsTypeFilter = 'all';
showTypePicker({ showTypePicker({
title: t('value_source.select_type'), title: t('value_source.select_type'),
items: _buildVSTypeItems(), items: _buildVSTypeItems(),
onPick: (type) => showValueSourceModal(null, type), 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; return;
} }
@@ -322,6 +382,38 @@ export async function showValueSourceModal(editData: any, presetType: any = null
_syncDaylightVSSpeedVisibility(); _syncDaylightVSSpeedVisibility();
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0); _setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1); _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 { } else {
(document.getElementById('value-source-name') as HTMLInputElement).value = ''; (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; (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked = false;
_setSlider('value-source-daylight-latitude', 50); _setSlider('value-source-daylight-latitude', 50);
_syncDaylightVSSpeedVisibility(); _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(); _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-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-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-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 = (document.getElementById('value-source-adaptive-range-section') as HTMLElement).style.display =
(type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none'; (type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none';
@@ -403,6 +521,28 @@ export function onValueSourceTypeChange() {
_populatePictureSourceDropdown(''); _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(); _autoGenerateVSName();
} }
@@ -471,6 +611,55 @@ export async function saveValueSource() {
payload.latitude = parseFloat((document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value); 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.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); 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 { try {
@@ -799,6 +988,54 @@ export function createValueSourceCard(src: ValueSource) {
${psBadge} ${psBadge}
<span class="stream-card-prop">${ICON_REFRESH} ${src.scene_behavior || 'complement'}</span> <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({ return wrapCard({
@@ -973,3 +1210,236 @@ function _populateScheduleUI(schedule: any) {
schedule.forEach(p => addSchedulePoint(p.time, p.value)); 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>) ─── // ─── Visual effects (called from inline <script>) ───
_updateBgAnimAccent: (accent: string) => void; _updateBgAnimAccent: (accent: string) => void;
_updateBgAnimTheme: (dark: boolean) => void; _updateBgAnimTheme: (dark: boolean) => void;
_updateTabIndicator: () => void; _updateTabIndicator: (tabName?: string) => void;
// ─── Core / UI ─── // ─── Core / UI ───
toggleHint: (...args: any[]) => any; toggleHint: (...args: any[]) => any;
@@ -286,6 +286,10 @@ startTargetOverlay: (...args: any[]) => any;
onValueSourceTypeChange: (...args: any[]) => any; onValueSourceTypeChange: (...args: any[]) => any;
onDaylightVSRealTimeChange: (...args: any[]) => any; onDaylightVSRealTimeChange: (...args: any[]) => any;
addSchedulePoint: (...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; testValueSource: (...args: any[]) => any;
closeTestValueSourceModal: (...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 ?? ''; 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 ──────────────────────────────────────────────────── // ── Device ────────────────────────────────────────────────────
export type DeviceType = export type DeviceType =
@@ -66,7 +85,14 @@ export interface Device {
export type TargetType = 'led' | 'ha_light'; 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; id: string;
name: string; name: string;
target_type: TargetType; target_type: TargetType;
@@ -74,32 +100,34 @@ export interface OutputTarget {
tags: string[]; tags: string[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}
// LED target fields export interface LedOutputTarget extends OutputTargetBase {
device_id?: string; target_type: 'led';
color_strip_source_id?: string; device_id: string;
color_strip_source_id: string;
brightness?: BindableFloat; brightness?: BindableFloat;
fps?: BindableFloat; fps?: BindableFloat;
keepalive_interval?: number; keepalive_interval: number;
state_check_interval?: number; state_check_interval: number;
min_brightness_threshold?: BindableFloat; min_brightness_threshold?: BindableFloat;
adaptive_fps?: boolean; adaptive_fps: boolean;
protocol?: string; protocol: string;
}
// HA light target fields export interface HALightOutputTarget extends OutputTargetBase {
ha_source_id?: string; target_type: 'ha_light';
ha_source_id: string;
color_strip_source_id: string;
brightness?: BindableFloat;
ha_light_mappings?: HALightMapping[]; ha_light_mappings?: HALightMapping[];
update_rate?: BindableFloat; update_rate?: BindableFloat;
transition?: BindableFloat; transition?: BindableFloat;
color_tolerance?: BindableFloat; color_tolerance?: BindableFloat;
min_brightness_threshold?: BindableFloat;
} }
export interface HALightMapping { export type OutputTarget = LedOutputTarget | HALightOutputTarget;
entity_id: string;
led_start: number;
led_end: number;
brightness_scale: BindableFloat;
}
// ── Color Strip Source ──────────────────────────────────────── // ── Color Strip Source ────────────────────────────────────────
@@ -188,8 +216,8 @@ export interface ColorStripSource {
interpolation_mode?: string; interpolation_mode?: string;
calibration?: Calibration; calibration?: Calibration;
// Static // Static / Effect / Candlelight
color?: number[]; color?: BindableColor;
// Gradient // Gradient
stops?: ColorStop[]; stops?: ColorStop[];
@@ -214,21 +242,21 @@ export interface ColorStripSource {
visualization_mode?: string; visualization_mode?: string;
audio_source_id?: string; audio_source_id?: string;
sensitivity?: BindableFloat; sensitivity?: BindableFloat;
color_peak?: number[]; color_peak?: BindableColor;
// Animation // Animation
animation?: AnimationConfig; animation?: AnimationConfig;
speed?: BindableFloat; speed?: BindableFloat;
// API Input // API Input
fallback_color?: number[]; fallback_color?: BindableColor;
timeout?: BindableFloat; timeout?: BindableFloat;
interpolation?: string; interpolation?: string;
// Notification // Notification
notification_effect?: string; notification_effect?: string;
duration_ms?: number; duration_ms?: number;
default_color?: string; default_color?: BindableColor | string;
app_colors?: Record<string, string>; app_colors?: Record<string, string>;
app_filter_mode?: string; app_filter_mode?: string;
app_filter_list?: string[]; app_filter_list?: string[];
@@ -280,79 +308,193 @@ export interface PatternTemplate {
export type ValueSourceType = export type ValueSourceType =
| 'static' | 'animated' | 'audio' | '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 { export interface SchedulePoint {
time: string; time: string;
value: number; value: number;
} }
export interface ValueSource { export interface ColorSchedulePoint {
time: string;
color: number[];
}
interface ValueSourceBase {
id: string; id: string;
name: string; name: string;
source_type: ValueSourceType; source_type: ValueSourceType;
return_type: 'float' | 'color';
description?: string; description?: string;
tags: string[]; tags: string[];
created_at: string; created_at: string;
updated_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 ─────────────────────────────────────────────── // ── Audio Source ───────────────────────────────────────────────
export interface AudioSource { export type AudioSourceType = 'multichannel' | 'mono' | 'band_extract';
interface AudioSourceBase {
id: string; id: string;
name: string; name: string;
source_type: 'multichannel' | 'mono' | 'band_extract'; source_type: AudioSourceType;
description?: string; description?: string;
tags: string[]; tags: string[];
created_at: string; created_at: string;
updated_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 ───────────────────────────────────────────── // ── Picture Source ─────────────────────────────────────────────
export type PictureSourceType = 'raw' | 'processed' | 'static_image' | 'video'; export type PictureSourceType = 'raw' | 'processed' | 'static_image' | 'video';
export interface PictureSource { interface PictureSourceBase {
id: string; id: string;
name: string; name: string;
stream_type: PictureSourceType; stream_type: PictureSourceType;
@@ -360,29 +502,44 @@ export interface PictureSource {
tags: string[]; tags: string[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}
// Raw export interface RawPictureSource extends PictureSourceBase {
display_index?: number; stream_type: 'raw';
capture_template_id?: string; display_index: number;
target_fps?: number; capture_template_id: string;
target_fps: number;
}
// Processed export interface ProcessedPictureSource extends PictureSourceBase {
source_stream_id?: string; stream_type: 'processed';
postprocessing_template_id?: string; source_stream_id: string;
postprocessing_template_id: string;
}
// Static image export interface StaticImagePictureSource extends PictureSourceBase {
stream_type: 'static_image';
image_asset_id?: string; image_asset_id?: string;
}
// Video export interface VideoPictureSource extends PictureSourceBase {
stream_type: 'video';
video_asset_id?: string; video_asset_id?: string;
loop?: boolean; loop: boolean;
playback_speed?: number; playback_speed: number;
start_time?: number; start_time?: number;
end_time?: number; end_time?: number;
resolution_limit?: number; resolution_limit?: number;
clock_id?: string; clock_id?: string;
target_fps: number;
} }
export type PictureSource =
| RawPictureSource
| ProcessedPictureSource
| StaticImagePictureSource
| VideoPictureSource;
// ── Scene Preset ────────────────────────────────────────────── // ── Scene Preset ──────────────────────────────────────────────
export interface TargetSnapshot { export interface TargetSnapshot {
@@ -1452,6 +1452,53 @@
"value_source.type.adaptive_scene.desc": "Adjusts by scene content", "value_source.type.adaptive_scene.desc": "Adjusts by scene content",
"value_source.type.daylight": "Daylight Cycle", "value_source.type.daylight": "Daylight Cycle",
"value_source.type.daylight.desc": "Brightness follows day/night 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": "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.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:", "value_source.daylight.use_real_time": "Use Real Time:",
@@ -1530,6 +1577,8 @@
"test.frames": "Frames", "test.frames": "Frames",
"test.fps": "FPS", "test.fps": "FPS",
"test.avg_capture": "Avg", "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": "Brightness Source:",
"targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)", "targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)",
"targets.brightness_vs.none": "None (device brightness)", "targets.brightness_vs.none": "None (device brightness)",
@@ -1440,6 +1440,8 @@
"test.frames": "Кадры", "test.frames": "Кадры",
"test.fps": "Кадр/с", "test.fps": "Кадр/с",
"test.avg_capture": "Сред", "test.avg_capture": "Сред",
"targets.brightness": "Яркость:",
"targets.brightness.hint": "Множитель яркости (0–1). Можно привязать к источнику значений для динамического управления.",
"targets.brightness_vs": "Источник яркости:", "targets.brightness_vs": "Источник яркости:",
"targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)", "targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)",
"targets.brightness_vs.none": "Нет (яркость устройства)", "targets.brightness_vs.none": "Нет (яркость устройства)",
@@ -1440,6 +1440,8 @@
"test.frames": "帧数", "test.frames": "帧数",
"test.fps": "帧率", "test.fps": "帧率",
"test.avg_capture": "平均", "test.avg_capture": "平均",
"targets.brightness": "亮度:",
"targets.brightness.hint": "输出亮度乘数(0–1)。可绑定到值源进行动态控制。",
"targets.brightness_vs": "亮度源:", "targets.brightness_vs": "亮度源:",
"targets.brightness_vs.hint": "可选的值源,每帧动态控制亮度(覆盖设备亮度)", "targets.brightness_vs.hint": "可选的值源,每帧动态控制亮度(覆盖设备亮度)",
"targets.brightness_vs.none": "无(设备亮度)", "targets.brightness_vs.none": "无(设备亮度)",
@@ -100,6 +100,76 @@ class BindableFloat:
return bool(self.source_id) 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: def bfloat(v, default: float = 0.0) -> float:
"""Extract the static float from a value that may be BindableFloat or plain number. """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_from_dict,
calibration_to_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 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: def _validate_rgb(value, default: list) -> list:
"""Return value if it's a 3-element list, otherwise return default.""" """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) 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. 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 animation: Optional[dict] = None # {"enabled": bool, "type": str, "speed": float} or None
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["color"] = list(self.color) d["color"] = self.color.to_dict()
d["animation"] = self.animation d["animation"] = self.animation
return d return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "StaticColorStripSource": def from_dict(cls, data: dict) -> "StaticColorStripSource":
common = _parse_css_common(data) common = _parse_css_common(data)
color = _validate_rgb(data.get("color"), [255, 255, 255])
return cls( return cls(
**common, **common,
source_type="static", source_type="static",
color=color, color=BindableColor.from_raw(data.get("color"), default=[255, 255, 255]),
animation=data.get("animation"), animation=data.get("animation"),
) )
@@ -385,7 +401,6 @@ class StaticColorStripSource(ColorStripSource):
animation=None, animation=None,
**_kwargs, **_kwargs,
): ):
rgb = _validate_rgb(color, [255, 255, 255])
return cls( return cls(
id=id, id=id,
name=name, name=name,
@@ -395,15 +410,14 @@ class StaticColorStripSource(ColorStripSource):
description=description, description=description,
clock_id=clock_id, clock_id=clock_id,
tags=tags or [], tags=tags or [],
color=rgb, color=BindableColor.from_raw(color, default=[255, 255, 255]),
animation=animation, animation=animation,
) )
def apply_update(self, **kwargs) -> None: def apply_update(self, **kwargs) -> None:
color = kwargs.get("color") color = kwargs.get("color")
if color is not None: if color is not None:
if isinstance(color, list) and len(color) == 3: self.color = self.color.apply_update(color)
self.color = color
if kwargs.get("animation") is not None: if kwargs.get("animation") is not None:
self.animation = kwargs["animation"] self.animation = kwargs["animation"]
@@ -591,8 +605,8 @@ class EffectColorStripSource(ColorStripSource):
effect_type: str = "fire" # fire | meteor | plasma | noise | aurora + new types effect_type: str = "fire" # fire | meteor | plasma | noise | aurora + new types
palette: str = "fire" # legacy palette name (kept for migration) palette: str = "fire" # legacy palette name (kept for migration)
gradient_id: Optional[str] = None # references a Gradient entity (preferred over palette) gradient_id: Optional[str] = None # references a Gradient entity (preferred over palette)
color: list = field( color: BindableColor = field(
default_factory=lambda: [255, 80, 0] default_factory=lambda: BindableColor([255, 80, 0])
) # [R,G,B] for meteor/comet/bouncing_ball head ) # [R,G,B] for meteor/comet/bouncing_ball head
intensity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0)) intensity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
scale: 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["effect_type"] = self.effect_type
d["palette"] = self.palette d["palette"] = self.palette
d["gradient_id"] = self.gradient_id d["gradient_id"] = self.gradient_id
d["color"] = list(self.color) d["color"] = self.color.to_dict()
d["intensity"] = self.intensity.to_dict() d["intensity"] = self.intensity.to_dict()
d["scale"] = self.scale.to_dict() d["scale"] = self.scale.to_dict()
d["mirror"] = self.mirror d["mirror"] = self.mirror
@@ -614,14 +628,13 @@ class EffectColorStripSource(ColorStripSource):
@classmethod @classmethod
def from_dict(cls, data: dict) -> "EffectColorStripSource": def from_dict(cls, data: dict) -> "EffectColorStripSource":
common = _parse_css_common(data) common = _parse_css_common(data)
color = _validate_rgb(data.get("color"), [255, 80, 0])
return cls( return cls(
**common, **common,
source_type="effect", source_type="effect",
effect_type=data.get("effect_type") or "fire", effect_type=data.get("effect_type") or "fire",
palette=data.get("palette") or "fire", palette=data.get("palette") or "fire",
gradient_id=data.get("gradient_id"), 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), intensity=BindableFloat.from_raw(data.get("intensity"), default=1.0),
scale=BindableFloat.from_raw(data.get("scale"), default=1.0), scale=BindableFloat.from_raw(data.get("scale"), default=1.0),
mirror=bool(data.get("mirror", False)), mirror=bool(data.get("mirror", False)),
@@ -650,7 +663,6 @@ class EffectColorStripSource(ColorStripSource):
custom_palette=None, custom_palette=None,
**_kwargs, **_kwargs,
): ):
rgb = _validate_rgb(color, [255, 80, 0])
return cls( return cls(
id=id, id=id,
name=name, name=name,
@@ -663,7 +675,7 @@ class EffectColorStripSource(ColorStripSource):
effect_type=effect_type or "fire", effect_type=effect_type or "fire",
palette=palette or "fire", palette=palette or "fire",
gradient_id=gradient_id, gradient_id=gradient_id,
color=rgb, color=BindableColor.from_raw(color, default=[255, 80, 0]),
intensity=BindableFloat.from_raw(intensity, default=1.0), intensity=BindableFloat.from_raw(intensity, default=1.0),
scale=BindableFloat.from_raw(scale, default=1.0), scale=BindableFloat.from_raw(scale, default=1.0),
mirror=bool(mirror), mirror=bool(mirror),
@@ -678,8 +690,8 @@ class EffectColorStripSource(ColorStripSource):
if "gradient_id" in kwargs: if "gradient_id" in kwargs:
self.gradient_id = kwargs["gradient_id"] self.gradient_id = kwargs["gradient_id"]
color = kwargs.get("color") color = kwargs.get("color")
if color is not None and isinstance(color, list) and len(color) == 3: if color is not None:
self.color = color self.color = self.color.apply_update(color)
if kwargs.get("intensity") is not None: if kwargs.get("intensity") is not None:
self.intensity = self.intensity.apply_update(kwargs["intensity"]) self.intensity = self.intensity.apply_update(kwargs["intensity"])
if kwargs.get("scale") is not None: if kwargs.get("scale") is not None:
@@ -706,8 +718,8 @@ class AudioColorStripSource(ColorStripSource):
smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3)) smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
palette: str = "rainbow" # legacy palette name (kept for migration) palette: str = "rainbow" # legacy palette name (kept for migration)
gradient_id: Optional[str] = None # references a Gradient entity (preferred) 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: BindableColor = field(default_factory=lambda: BindableColor([0, 255, 0]))
color_peak: list = field(default_factory=lambda: [255, 0, 0]) # peak RGB for VU meter color_peak: BindableColor = field(default_factory=lambda: BindableColor([255, 0, 0]))
led_count: int = 0 # 0 = use device LED count led_count: int = 0 # 0 = use device LED count
mirror: bool = False # mirror spectrum from center outward mirror: bool = False # mirror spectrum from center outward
@@ -719,8 +731,8 @@ class AudioColorStripSource(ColorStripSource):
d["smoothing"] = self.smoothing.to_dict() d["smoothing"] = self.smoothing.to_dict()
d["palette"] = self.palette d["palette"] = self.palette
d["gradient_id"] = self.gradient_id d["gradient_id"] = self.gradient_id
d["color"] = list(self.color) d["color"] = self.color.to_dict()
d["color_peak"] = list(self.color_peak) d["color_peak"] = self.color_peak.to_dict()
d["led_count"] = self.led_count d["led_count"] = self.led_count
d["mirror"] = self.mirror d["mirror"] = self.mirror
return d return d
@@ -728,8 +740,6 @@ class AudioColorStripSource(ColorStripSource):
@classmethod @classmethod
def from_dict(cls, data: dict) -> "AudioColorStripSource": def from_dict(cls, data: dict) -> "AudioColorStripSource":
common = _parse_css_common(data) 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( return cls(
**common, **common,
source_type="audio", source_type="audio",
@@ -739,8 +749,8 @@ class AudioColorStripSource(ColorStripSource):
smoothing=BindableFloat.from_raw(data.get("smoothing"), default=0.3), smoothing=BindableFloat.from_raw(data.get("smoothing"), default=0.3),
palette=data.get("palette") or "rainbow", palette=data.get("palette") or "rainbow",
gradient_id=data.get("gradient_id"), gradient_id=data.get("gradient_id"),
color=color, color=BindableColor.from_raw(data.get("color"), default=[0, 255, 0]),
color_peak=color_peak, color_peak=BindableColor.from_raw(data.get("color_peak"), default=[255, 0, 0]),
led_count=data.get("led_count") or 0, led_count=data.get("led_count") or 0,
mirror=bool(data.get("mirror", False)), mirror=bool(data.get("mirror", False)),
) )
@@ -769,8 +779,6 @@ class AudioColorStripSource(ColorStripSource):
mirror=False, mirror=False,
**_kwargs, **_kwargs,
): ):
rgb = _validate_rgb(color, [0, 255, 0])
peak = _validate_rgb(color_peak, [255, 0, 0])
return cls( return cls(
id=id, id=id,
name=name, name=name,
@@ -786,8 +794,8 @@ class AudioColorStripSource(ColorStripSource):
smoothing=BindableFloat.from_raw(smoothing, default=0.3), smoothing=BindableFloat.from_raw(smoothing, default=0.3),
palette=palette or "rainbow", palette=palette or "rainbow",
gradient_id=gradient_id, gradient_id=gradient_id,
color=rgb, color=BindableColor.from_raw(color, default=[0, 255, 0]),
color_peak=peak, color_peak=BindableColor.from_raw(color_peak, default=[255, 0, 0]),
led_count=led_count, led_count=led_count,
mirror=bool(mirror), mirror=bool(mirror),
) )
@@ -807,11 +815,11 @@ class AudioColorStripSource(ColorStripSource):
if "gradient_id" in kwargs: if "gradient_id" in kwargs:
self.gradient_id = kwargs["gradient_id"] self.gradient_id = kwargs["gradient_id"]
color = kwargs.get("color") color = kwargs.get("color")
if color is not None and isinstance(color, list) and len(color) == 3: if color is not None:
self.color = color self.color = self.color.apply_update(color)
color_peak = kwargs.get("color_peak") color_peak = kwargs.get("color_peak")
if color_peak is not None and isinstance(color_peak, list) and len(color_peak) == 3: if color_peak is not None:
self.color_peak = color_peak self.color_peak = self.color_peak.apply_update(color_peak)
if kwargs.get("led_count") is not None: if kwargs.get("led_count") is not None:
self.led_count = kwargs["led_count"] self.led_count = kwargs["led_count"]
if kwargs.get("mirror") is not None: if kwargs.get("mirror") is not None:
@@ -963,13 +971,13 @@ class ApiInputColorStripSource(ColorStripSource):
LED count auto-sizes from the connected device via configure(). 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)) timeout: BindableFloat = field(default_factory=lambda: BindableFloat(5.0))
interpolation: str = "linear" # none | linear | nearest interpolation: str = "linear" # none | linear | nearest
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_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["timeout"] = self.timeout.to_dict()
d["interpolation"] = self.interpolation d["interpolation"] = self.interpolation
return d return d
@@ -977,7 +985,7 @@ class ApiInputColorStripSource(ColorStripSource):
@classmethod @classmethod
def from_dict(cls, data: dict) -> "ApiInputColorStripSource": def from_dict(cls, data: dict) -> "ApiInputColorStripSource":
common = _parse_css_common(data) 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") interpolation = data.get("interpolation", "linear")
if interpolation not in ("none", "linear", "nearest"): if interpolation not in ("none", "linear", "nearest"):
interpolation = "linear" interpolation = "linear"
@@ -1006,7 +1014,7 @@ class ApiInputColorStripSource(ColorStripSource):
interpolation=None, interpolation=None,
**_kwargs, **_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" interp = interpolation if interpolation in ("none", "linear", "nearest") else "linear"
return cls( return cls(
id=id, id=id,
@@ -1024,12 +1032,8 @@ class ApiInputColorStripSource(ColorStripSource):
def apply_update(self, **kwargs) -> None: def apply_update(self, **kwargs) -> None:
fallback_color = kwargs.get("fallback_color") fallback_color = kwargs.get("fallback_color")
if ( if fallback_color is not None:
fallback_color is not None self.fallback_color = self.fallback_color.apply_update(fallback_color)
and isinstance(fallback_color, list)
and len(fallback_color) == 3
):
self.fallback_color = fallback_color
if kwargs.get("timeout") is not None: if kwargs.get("timeout") is not None:
self.timeout = self.timeout.apply_update(kwargs["timeout"]) self.timeout = self.timeout.apply_update(kwargs["timeout"])
interpolation = kwargs.get("interpolation") interpolation = kwargs.get("interpolation")
@@ -1050,7 +1054,7 @@ class NotificationColorStripSource(ColorStripSource):
notification_effect: str = "flash" # flash | pulse | sweep notification_effect: str = "flash" # flash | pulse | sweep
duration_ms: BindableFloat = field(default_factory=lambda: BindableFloat(1500.0)) 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_colors: dict = field(default_factory=dict) # app name -> hex color
app_filter_mode: str = "off" # off | whitelist | blacklist app_filter_mode: str = "off" # off | whitelist | blacklist
app_filter_list: list = field(default_factory=list) # app names for filter app_filter_list: list = field(default_factory=list) # app names for filter
@@ -1065,7 +1069,7 @@ class NotificationColorStripSource(ColorStripSource):
d = super().to_dict() d = super().to_dict()
d["notification_effect"] = self.notification_effect d["notification_effect"] = self.notification_effect
d["duration_ms"] = self.duration_ms.to_dict() 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_colors"] = dict(self.app_colors)
d["app_filter_mode"] = self.app_filter_mode d["app_filter_mode"] = self.app_filter_mode
d["app_filter_list"] = list(self.app_filter_list) d["app_filter_list"] = list(self.app_filter_list)
@@ -1086,7 +1090,7 @@ class NotificationColorStripSource(ColorStripSource):
source_type="notification", source_type="notification",
notification_effect=data.get("notification_effect") or "flash", notification_effect=data.get("notification_effect") or "flash",
duration_ms=BindableFloat.from_raw(data.get("duration_ms"), default=1500.0), 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_colors=raw_app_colors if isinstance(raw_app_colors, dict) else {},
app_filter_mode=data.get("app_filter_mode") or "off", 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 [], 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 [], tags=tags or [],
notification_effect=notification_effect or "flash", notification_effect=notification_effect or "flash",
duration_ms=BindableFloat.from_raw(duration_ms, default=1500.0), 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_colors=app_colors if isinstance(app_colors, dict) else {},
app_filter_mode=app_filter_mode or "off", app_filter_mode=app_filter_mode or "off",
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [], 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: if kwargs.get("duration_ms") is not None:
self.duration_ms = self.duration_ms.apply_update(kwargs["duration_ms"]) self.duration_ms = self.duration_ms.apply_update(kwargs["duration_ms"])
if kwargs.get("default_color") is not None: 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") app_colors = kwargs.get("app_colors")
if app_colors is not None and isinstance(app_colors, dict): if app_colors is not None and isinstance(app_colors, dict):
self.app_colors = app_colors self.app_colors = app_colors
@@ -1259,7 +1267,7 @@ class CandlelightColorStripSource(ColorStripSource):
LED count auto-sizes from the connected device. 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)) intensity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
num_candles: int = 3 # number of independent candle sources num_candles: int = 3 # number of independent candle sources
speed: BindableFloat = field(default_factory=lambda: BindableFloat(1.0)) speed: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
@@ -1268,7 +1276,7 @@ class CandlelightColorStripSource(ColorStripSource):
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["color"] = list(self.color) d["color"] = self.color.to_dict()
d["intensity"] = self.intensity.to_dict() d["intensity"] = self.intensity.to_dict()
d["num_candles"] = self.num_candles d["num_candles"] = self.num_candles
d["speed"] = self.speed.to_dict() d["speed"] = self.speed.to_dict()
@@ -1279,11 +1287,10 @@ class CandlelightColorStripSource(ColorStripSource):
@classmethod @classmethod
def from_dict(cls, data: dict) -> "CandlelightColorStripSource": def from_dict(cls, data: dict) -> "CandlelightColorStripSource":
common = _parse_css_common(data) common = _parse_css_common(data)
color = _validate_rgb(data.get("color"), [255, 147, 41])
return cls( return cls(
**common, **common,
source_type="candlelight", 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), intensity=BindableFloat.from_raw(data.get("intensity"), default=1.0),
num_candles=int(data.get("num_candles") or 3), num_candles=int(data.get("num_candles") or 3),
speed=BindableFloat.from_raw(data.get("speed"), default=1.0), speed=BindableFloat.from_raw(data.get("speed"), default=1.0),
@@ -1311,7 +1318,6 @@ class CandlelightColorStripSource(ColorStripSource):
candle_type=None, candle_type=None,
**_kwargs, **_kwargs,
): ):
rgb = _validate_rgb(color, [255, 147, 41])
return cls( return cls(
id=id, id=id,
name=name, name=name,
@@ -1321,7 +1327,7 @@ class CandlelightColorStripSource(ColorStripSource):
description=description, description=description,
clock_id=clock_id, clock_id=clock_id,
tags=tags or [], tags=tags or [],
color=rgb, color=BindableColor.from_raw(color, default=[255, 147, 41]),
intensity=BindableFloat.from_raw(intensity, default=1.0), intensity=BindableFloat.from_raw(intensity, default=1.0),
num_candles=int(num_candles) if num_candles is not None else 3, num_candles=int(num_candles) if num_candles is not None else 3,
speed=BindableFloat.from_raw(speed, default=1.0), speed=BindableFloat.from_raw(speed, default=1.0),
@@ -1335,8 +1341,8 @@ class CandlelightColorStripSource(ColorStripSource):
def apply_update(self, **kwargs) -> None: def apply_update(self, **kwargs) -> None:
color = kwargs.get("color") color = kwargs.get("color")
if color is not None and isinstance(color, list) and len(color) == 3: if color is not None:
self.color = color self.color = self.color.apply_update(color)
if kwargs.get("intensity") is not None: if kwargs.get("intensity") is not None:
self.intensity = self.intensity.apply_update(kwargs["intensity"]) self.intensity = self.intensity.apply_update(kwargs["intensity"])
if kwargs.get("num_candles") is not None: if kwargs.get("num_candles") is not None:
@@ -1,7 +1,9 @@
"""Value source data model with inheritance-based source types. """Value source data model with inheritance-based source types.
A ValueSource produces a scalar float (0.01.0) that can drive target A ValueSource produces either a scalar float (0.01.0) or an RGB color [R,G,B]
parameters like brightness. Six types: depending on ``return_type`` ("float" or "color").
Float types (return_type="float"):
StaticValueSource constant float value StaticValueSource constant float value
AnimatedValueSource periodic waveform (sine, triangle, square, sawtooth) AnimatedValueSource periodic waveform (sine, triangle, square, sawtooth)
AudioValueSource audio-reactive scalar (RMS, peak, beat detection) 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_time interpolates brightness along a 24-hour schedule
adaptive_scene derives brightness from a picture source's frame luminance adaptive_scene derives brightness from a picture source's frame luminance
DaylightValueSource brightness based on simulated or real-time daylight cycle 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 from dataclasses import dataclass, field
@@ -22,7 +29,9 @@ class ValueSource:
id: str id: str
name: 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 created_at: datetime
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
@@ -70,15 +79,13 @@ def _parse_common_fields(data: dict) -> dict:
created_at = ( created_at = (
datetime.fromisoformat(raw_created) datetime.fromisoformat(raw_created)
if isinstance(raw_created, str) if isinstance(raw_created, str)
else raw_created if isinstance(raw_created, datetime) else raw_created if isinstance(raw_created, datetime) else datetime.now(timezone.utc)
else datetime.now(timezone.utc)
) )
raw_updated = data.get("updated_at") raw_updated = data.get("updated_at")
updated_at = ( updated_at = (
datetime.fromisoformat(raw_updated) datetime.fromisoformat(raw_updated)
if isinstance(raw_updated, str) if isinstance(raw_updated, str)
else raw_updated if isinstance(raw_updated, datetime) else raw_updated if isinstance(raw_updated, datetime) else datetime.now(timezone.utc)
else datetime.now(timezone.utc)
) )
return dict( return dict(
id=data["id"], id=data["id"],
@@ -122,10 +129,10 @@ class AnimatedValueSource(ValueSource):
at the configured speed (cycles per minute). at the configured speed (cycles per minute).
""" """
waveform: str = "sine" # sine | triangle | square | sawtooth waveform: str = "sine" # sine | triangle | square | sawtooth
speed: float = 10.0 # cycles per minute (1.0120.0) speed: float = 10.0 # cycles per minute (1.0120.0)
min_value: float = 0.0 # minimum output (0.01.0) min_value: float = 0.0 # minimum output (0.01.0)
max_value: float = 1.0 # maximum output (0.01.0) max_value: float = 1.0 # maximum output (0.01.0)
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
@@ -156,13 +163,13 @@ class AudioValueSource(ValueSource):
into a scalar value for brightness modulation. into a scalar value for brightness modulation.
""" """
audio_source_id: str = "" # references an audio source (mono or multichannel) audio_source_id: str = "" # references an audio source (mono or multichannel)
mode: str = "rms" # rms | peak | beat mode: str = "rms" # rms | peak | beat
sensitivity: float = 1.0 # gain multiplier (0.120.0) sensitivity: float = 1.0 # gain multiplier (0.120.0)
smoothing: float = 0.3 # temporal smoothing (0.01.0) smoothing: float = 0.3 # temporal smoothing (0.01.0)
min_value: float = 0.0 # minimum output (0.01.0) min_value: float = 0.0 # minimum output (0.01.0)
max_value: float = 1.0 # maximum 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 auto_gain: bool = False # auto-normalize audio levels to full range
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_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}] schedule: List[dict] = field(default_factory=list) # [{time: "HH:MM", value: 0.0-1.0}]
picture_source_id: str = "" # for scene mode picture_source_id: str = "" # for scene mode
scene_behavior: str = "complement" # "complement" | "match" scene_behavior: str = "complement" # "complement" | "match"
sensitivity: float = 1.0 # gain multiplier (0.1-5.0) sensitivity: float = 1.0 # gain multiplier (0.1-5.0)
smoothing: float = 0.3 # temporal smoothing (0.0-1.0) smoothing: float = 0.3 # temporal smoothing (0.0-1.0)
min_value: float = 0.0 # output range min min_value: float = 0.0 # output range min
max_value: float = 1.0 # output range max max_value: float = 1.0 # output range max
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
@@ -244,11 +251,11 @@ class DaylightValueSource(ValueSource):
scalar brightness from the simulated (or real-time) sky color luminance. scalar brightness from the simulated (or real-time) sky color luminance.
""" """
speed: float = 1.0 # simulation speed (ignored when use_real_time) speed: float = 1.0 # simulation speed (ignored when use_real_time)
use_real_time: bool = False # use wall clock instead of simulation use_real_time: bool = False # use wall clock instead of simulation
latitude: float = 50.0 # affects sunrise/sunset in real-time mode latitude: float = 50.0 # affects sunrise/sunset in real-time mode
min_value: float = 0.0 # output range min min_value: float = 0.0 # output range min
max_value: float = 1.0 # output range max max_value: float = 1.0 # output range max
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_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 -- # -- Source type registry --
# Maps source_type string to its subclass for factory dispatch. # Maps source_type string to its subclass for factory dispatch.
_VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = { _VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = {
@@ -282,4 +476,10 @@ _VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = {
"adaptive_time": AdaptiveValueSource, "adaptive_time": AdaptiveValueSource,
"adaptive_scene": AdaptiveValueSource, "adaptive_scene": AdaptiveValueSource,
"daylight": DaylightValueSource, "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.utils import resolve_ref
from wled_controller.storage.value_source import ( from wled_controller.storage.value_source import (
AdaptiveValueSource, AdaptiveValueSource,
AdaptiveTimeColorValueSource,
AnimatedColorValueSource,
AnimatedValueSource, AnimatedValueSource,
AudioValueSource, AudioValueSource,
CSSExtractValueSource,
DaylightValueSource, DaylightValueSource,
GradientMapValueSource,
HAEntityValueSource,
StaticColorValueSource,
StaticValueSource, StaticValueSource,
ValueSource, ValueSource,
) )
@@ -56,9 +62,36 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
auto_gain: Optional[bool] = None, auto_gain: Optional[bool] = None,
use_real_time: Optional[bool] = None, use_real_time: Optional[bool] = None,
latitude: Optional[float] = None, latitude: Optional[float] = None,
color: Optional[list] = None,
colors: Optional[list] = None,
easing: Optional[str] = None,
tags: Optional[List[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: ) -> 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}") raise ValueError(f"Invalid source type: {source_type}")
self._check_name_unique(name) self._check_name_unique(name)
@@ -70,14 +103,24 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
if source_type == "static": if source_type == "static":
source: ValueSource = StaticValueSource( source: ValueSource = StaticValueSource(
id=sid, name=name, source_type="static", id=sid,
created_at=now, updated_at=now, description=description, tags=common_tags, 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, value=value if value is not None else 1.0,
) )
elif source_type == "animated": elif source_type == "animated":
source = AnimatedValueSource( source = AnimatedValueSource(
id=sid, name=name, source_type="animated", id=sid,
created_at=now, updated_at=now, description=description, tags=common_tags, name=name,
source_type="animated",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
waveform=waveform or "sine", waveform=waveform or "sine",
speed=speed if speed is not None else 10.0, speed=speed if speed is not None else 10.0,
min_value=min_value if min_value is not None else 0.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": elif source_type == "audio":
source = AudioValueSource( source = AudioValueSource(
id=sid, name=name, source_type="audio", id=sid,
created_at=now, updated_at=now, description=description, tags=common_tags, name=name,
source_type="audio",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
audio_source_id=audio_source_id or "", audio_source_id=audio_source_id or "",
mode=mode or "rms", mode=mode or "rms",
sensitivity=sensitivity if sensitivity is not None else 1.0, sensitivity=sensitivity if sensitivity is not None else 1.0,
@@ -100,16 +148,26 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
if len(schedule_data) < 2: if len(schedule_data) < 2:
raise ValueError("Time of day schedule requires at least 2 points") raise ValueError("Time of day schedule requires at least 2 points")
source = AdaptiveValueSource( source = AdaptiveValueSource(
id=sid, name=name, source_type="adaptive_time", id=sid,
created_at=now, updated_at=now, description=description, tags=common_tags, name=name,
source_type="adaptive_time",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
schedule=schedule_data, schedule=schedule_data,
min_value=min_value if min_value is not None else 0.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, max_value=max_value if max_value is not None else 1.0,
) )
elif source_type == "adaptive_scene": elif source_type == "adaptive_scene":
source = AdaptiveValueSource( source = AdaptiveValueSource(
id=sid, name=name, source_type="adaptive_scene", id=sid,
created_at=now, updated_at=now, description=description, tags=common_tags, name=name,
source_type="adaptive_scene",
created_at=now,
updated_at=now,
description=description,
tags=common_tags,
picture_source_id=picture_source_id or "", picture_source_id=picture_source_id or "",
scene_behavior=scene_behavior or "complement", scene_behavior=scene_behavior or "complement",
sensitivity=sensitivity if sensitivity is not None else 1.0, sensitivity=sensitivity if sensitivity is not None else 1.0,
@@ -119,14 +177,111 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
) )
elif source_type == "daylight": elif source_type == "daylight":
source = DaylightValueSource( source = DaylightValueSource(
id=sid, name=name, source_type="daylight", id=sid,
created_at=now, updated_at=now, description=description, tags=common_tags, 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, 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, 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, latitude=latitude if latitude is not None else 50.0,
min_value=min_value if min_value is not None else 0.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, 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._items[sid] = source
self._save_item(sid, source) self._save_item(sid, source)
@@ -154,7 +309,20 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
auto_gain: Optional[bool] = None, auto_gain: Optional[bool] = None,
use_real_time: Optional[bool] = None, use_real_time: Optional[bool] = None,
latitude: Optional[float] = None, latitude: Optional[float] = None,
color: Optional[list] = None,
colors: Optional[list] = None,
easing: Optional[str] = None,
tags: Optional[List[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: ) -> ValueSource:
source = self.get(source_id) source = self.get(source_id)
@@ -222,6 +390,50 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
source.min_value = min_value source.min_value = min_value
if max_value is not None: if max_value is not None:
source.max_value = max_value 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) source.updated_at = datetime.now(timezone.utc)
self._save_item(source_id, source) self._save_item(source_id, source)
@@ -9,6 +9,7 @@
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="calibration-device-id"> <input type="hidden" id="calibration-device-id">
<input type="hidden" id="calibration-css-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 --> <!-- 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 id="calibration-css-test-group" class="form-group" style="display:none; margin-bottom: 12px; padding: 0 4px;">
<div class="label-row"> <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> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </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> <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>
</div> </div>
@@ -170,7 +170,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.color.hint">Head color for the meteor effect.</small> <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>
<div id="css-editor-effect-intensity-group" class="form-group"> <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> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.audio.color.hint">Low-level color for VU meter bar.</small> <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>
<div id="css-editor-audio-color-peak-group" class="form-group" style="display:none"> <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> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </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> <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>
<div id="css-editor-audio-mirror-group" class="form-group" style="display:none"> <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> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </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> <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>
<div class="form-group"> <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> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </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> <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>
<div class="form-group"> <div class="form-group">
@@ -468,15 +468,13 @@
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-notification-volume"> <label>
<span data-i18n="color_strip.notification.sound.volume">Volume:</span> <span data-i18n="color_strip.notification.sound.volume">Volume:</span>
<span id="css-editor-notification-volume-val">100%</span>
</label> </label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.sound.volume.hint">Global volume for notification sounds (0100%).</small> <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" <div id="css-editor-notification-volume-container"></div>
oninput="document.getElementById('css-editor-notification-volume-val').textContent = this.value + '%'">
</div> </div>
</div> </div>
</details> </details>
@@ -556,7 +554,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </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> <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>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
@@ -60,14 +60,14 @@
<div id="ha-light-editor-transition-container"></div> <div id="ha-light-editor-transition-container"></div>
</div> </div>
<!-- Brightness Value Source --> <!-- Brightness -->
<div class="form-group"> <div class="form-group">
<div class="label-row"> <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> </div>
<select id="ha-light-editor-brightness-vs"> <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>
<option value="">None</option> <div id="ha-light-editor-brightness-container"></div>
</select>
</div> </div>
<!-- Color Tolerance --> <!-- Color Tolerance -->
@@ -36,13 +36,11 @@
<div class="form-group"> <div class="form-group">
<div class="label-row"> <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> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </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> <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>
<select id="target-editor-brightness-vs"> <div id="target-editor-brightness-container"></div>
<option value="" data-i18n="targets.brightness_vs.none">None (device brightness)</option>
</select>
</div> </div>
<div class="form-group" id="target-editor-fps-group"> <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_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="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="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> </select>
</div> </div>
@@ -274,6 +280,181 @@
</div> </div>
</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) --> <!-- Shared adaptive output range (shown for adaptive and daylight types) -->
<div id="value-source-adaptive-range-section" style="display:none"> <div id="value-source-adaptive-range-section" style="display:none">
<div class="form-group"> <div class="form-group">
+62 -45
View File
@@ -4,19 +4,21 @@ Tests creating, listing, updating, cloning, and deleting color strip sources.
""" """
class TestColorStripSourceLifecycle: class TestColorStripSourceLifecycle:
"""A user manages color strip sources for LED effects.""" """A user manages color strip sources for LED effects."""
def test_static_and_gradient_crud(self, client): def test_static_and_gradient_crud(self, client):
# 1. Create a static color strip source # 1. Create a static color strip source
resp = client.post("/api/v1/color-strip-sources", json={ resp = client.post(
"name": "Red Static", "/api/v1/color-strip-sources",
"source_type": "static", json={
"color": [255, 0, 0], "name": "Red Static",
"led_count": 60, "source_type": "static",
"tags": ["e2e", "static"], "color": [255, 0, 0],
}) "led_count": 60,
"tags": ["e2e", "static"],
},
)
assert resp.status_code == 201, f"Create static failed: {resp.text}" assert resp.status_code == 201, f"Create static failed: {resp.text}"
static = resp.json() static = resp.json()
static_id = static["id"] static_id = static["id"]
@@ -25,15 +27,18 @@ class TestColorStripSourceLifecycle:
assert static["color"] == [255, 0, 0] assert static["color"] == [255, 0, 0]
# 2. Create a gradient color strip source # 2. Create a gradient color strip source
resp = client.post("/api/v1/color-strip-sources", json={ resp = client.post(
"name": "Blue-Green Gradient", "/api/v1/color-strip-sources",
"source_type": "gradient", json={
"stops": [ "name": "Blue-Green Gradient",
{"position": 0.0, "color": [0, 0, 255]}, "source_type": "gradient",
{"position": 1.0, "color": [0, 255, 0]}, "stops": [
], {"position": 0.0, "color": [0, 0, 255]},
"led_count": 60, {"position": 1.0, "color": [0, 255, 0]},
}) ],
"led_count": 60,
},
)
assert resp.status_code == 201, f"Create gradient failed: {resp.text}" assert resp.status_code == 201, f"Create gradient failed: {resp.text}"
gradient = resp.json() gradient = resp.json()
gradient_id = gradient["id"] gradient_id = gradient["id"]
@@ -53,7 +58,7 @@ class TestColorStripSourceLifecycle:
# 4. Update the static source -- change color # 4. Update the static source -- change color
resp = client.put( resp = client.put(
f"/api/v1/color-strip-sources/{static_id}", 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.status_code == 200
assert resp.json()["color"] == [0, 255, 0] assert resp.json()["color"] == [0, 255, 0]
@@ -64,12 +69,15 @@ class TestColorStripSourceLifecycle:
assert resp.json()["color"] == [0, 255, 0] assert resp.json()["color"] == [0, 255, 0]
# 6. Clone by creating another source with same data, different name # 6. Clone by creating another source with same data, different name
resp = client.post("/api/v1/color-strip-sources", json={ resp = client.post(
"name": "Cloned Static", "/api/v1/color-strip-sources",
"source_type": "static", json={
"color": [0, 255, 0], "name": "Cloned Static",
"led_count": 60, "source_type": "static",
}) "color": [0, 255, 0],
"led_count": 60,
},
)
assert resp.status_code == 201 assert resp.status_code == 201
clone_id = resp.json()["id"] clone_id = resp.json()["id"]
assert clone_id != static_id assert clone_id != static_id
@@ -87,17 +95,20 @@ class TestColorStripSourceLifecycle:
def test_update_name(self, client): def test_update_name(self, client):
"""Renaming a color strip source persists.""" """Renaming a color strip source persists."""
resp = client.post("/api/v1/color-strip-sources", json={ resp = client.post(
"name": "Original Name", "/api/v1/color-strip-sources",
"source_type": "static", json={
"color": [100, 100, 100], "name": "Original Name",
"led_count": 10, "source_type": "static",
}) "color": [100, 100, 100],
"led_count": 10,
},
)
source_id = resp.json()["id"] source_id = resp.json()["id"]
resp = client.put( resp = client.put(
f"/api/v1/color-strip-sources/{source_id}", 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.status_code == 200
assert resp.json()["name"] == "New Name" assert resp.json()["name"] == "New Name"
@@ -126,12 +137,15 @@ class TestColorStripSourceLifecycle:
def test_color_cycle_source(self, client): def test_color_cycle_source(self, client):
"""Color cycle sources store and return their color list.""" """Color cycle sources store and return their color list."""
resp = client.post("/api/v1/color-strip-sources", json={ resp = client.post(
"name": "Rainbow Cycle", "/api/v1/color-strip-sources",
"source_type": "color_cycle", json={
"colors": [[255, 0, 0], [0, 255, 0], [0, 0, 255]], "name": "Rainbow Cycle",
"led_count": 30, "source_type": "color_cycle",
}) "colors": [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
"led_count": 30,
},
)
assert resp.status_code == 201 assert resp.status_code == 201
data = resp.json() data = resp.json()
assert data["source_type"] == "color_cycle" assert data["source_type"] == "color_cycle"
@@ -139,14 +153,17 @@ class TestColorStripSourceLifecycle:
def test_effect_source(self, client): def test_effect_source(self, client):
"""Effect sources store their effect parameters.""" """Effect sources store their effect parameters."""
resp = client.post("/api/v1/color-strip-sources", json={ resp = client.post(
"name": "Fire Effect", "/api/v1/color-strip-sources",
"source_type": "effect", json={
"effect_type": "fire", "name": "Fire Effect",
"palette": "fire", "source_type": "effect",
"intensity": 1.5, "effect_type": "fire",
"led_count": 60, "palette": "fire",
}) "intensity": 1.5,
"led_count": 60,
},
)
assert resp.status_code == 201 assert resp.status_code == 201
data = resp.json() data = resp.json()
assert data["source_type"] == "effect" 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: class TestOutputTargetLifecycle:
"""A user wires up an output target to a device.""" """A user wires up an output target to a device."""
def _create_device(self, client) -> str: def _create_device(self, client) -> str:
"""Helper: create a mock device and return its ID.""" """Helper: create a mock device and return its ID."""
resp = client.post("/api/v1/devices", json={ resp = client.post(
"name": "Target Test Device", "/api/v1/devices",
"url": "mock://target-test", json={
"device_type": "mock", "name": "Target Test Device",
"led_count": 60, "url": "mock://target-test",
}) "device_type": "mock",
"led_count": 60,
},
)
assert resp.status_code == 201 assert resp.status_code == 201
return resp.json()["id"] return resp.json()["id"]
@@ -44,7 +46,7 @@ class TestOutputTargetLifecycle:
assert target["name"] == "E2E Test Target" assert target["name"] == "E2E Test Target"
assert target["device_id"] == device_id assert target["device_id"] == device_id
assert target["target_type"] == "led" assert target["target_type"] == "led"
assert target["fps"] == 30 assert target["fps"] == 30.0
assert target["protocol"] == "ddp" assert target["protocol"] == "ddp"
# 3. List targets -- should contain the new target # 3. List targets -- should contain the new target
@@ -62,12 +64,12 @@ class TestOutputTargetLifecycle:
# 5. Update the target -- change name and fps # 5. Update the target -- change name and fps
resp = client.put( resp = client.put(
f"/api/v1/output-targets/{target_id}", 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 assert resp.status_code == 200
updated = resp.json() updated = resp.json()
assert updated["name"] == "Updated Target" assert updated["name"] == "Updated Target"
assert updated["fps"] == 60 assert updated["fps"] == 60.0
# 6. Verify update via GET # 6. Verify update via GET
resp = client.get(f"/api/v1/output-targets/{target_id}") 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.""" """Deleting a device that has a target should return 409."""
device_id = self._create_device(client) device_id = self._create_device(client)
resp = client.post("/api/v1/output-targets", json={ resp = client.post(
"name": "Blocking Target", "/api/v1/output-targets",
"target_type": "led", json={
"device_id": device_id, "name": "Blocking Target",
}) "target_type": "led",
"device_id": device_id,
},
)
assert resp.status_code == 201 assert resp.status_code == 201
target_id = resp.json()["id"] target_id = resp.json()["id"]
@@ -111,11 +116,14 @@ class TestOutputTargetLifecycle:
def test_create_target_with_invalid_device_returns_422(self, client): def test_create_target_with_invalid_device_returns_422(self, client):
"""Creating a target with a non-existent device_id returns 422.""" """Creating a target with a non-existent device_id returns 422."""
resp = client.post("/api/v1/output-targets", json={ resp = client.post(
"name": "Orphan Target", "/api/v1/output-targets",
"target_type": "led", json={
"device_id": "nonexistent_device", "name": "Orphan Target",
}) "target_type": "led",
"device_id": "nonexistent_device",
},
)
assert resp.status_code == 422 assert resp.status_code == 422
def test_get_nonexistent_target_returns_404(self, client): def test_get_nonexistent_target_returns_404(self, client):