feat: new value source types (HA entity, gradient map, strip extract) + UI fixes
Lint & Test / test (push) Successful in 1m27s
Lint & Test / test (push) Successful in 1m27s
New value source types: - ha_entity: reads numeric values from HA entity state/attribute, normalizes via min/max range, applies EMA smoothing. EntitySelect for HA connection and entity selection with live entity list fetching. - gradient_map: maps a float value source (0-1) through a gradient entity. EntitySelect for both input source and gradient with inline previews. - css_extract: extracts single color by averaging LED range from a color strip source. EntitySelect for source selection. Value source type picker: - Filter tabs (All / Numeric / Color) above the icon grid - showTypePicker extended with filterTabs + onFilterChange support Palette selectors converted to EntitySelect: - Effect palette, gradient preset, and audio palette selectors now use command-palette style EntitySelect with gradient strip previews Tab indicator fixes: - Icon now updates on tab switch (was passing no args to updateTabIndicator) - Visible with any background effect active, not just Noise Field - Noise Field is the default background effect for new users Dashboard section collapse fix: - Split header into clickable toggle (chevron+label) and non-clickable actions area — buttons no longer trigger collapse/expand Discriminated union fix (422 errors): - source_type/target_type now always included in update payloads for: CSS editor, LED target, HA light target, simple calibration, advanced calibration
This commit is contained in:
@@ -1,38 +1,231 @@
|
||||
# BindableFloat — Universal Value Source Binding
|
||||
# New Value Source Types + Filter Support
|
||||
|
||||
## ALL PHASES COMPLETE
|
||||
## Feature 1: HA Value Source (`ha_entity`)
|
||||
|
||||
### Phase 1: Core Infrastructure
|
||||
A value source that reads numeric values from a Home Assistant entity's state or attribute. Allows binding any scalar property in the system to a live HA sensor/entity value.
|
||||
|
||||
- [x] `storage/bindable.py` — BindableFloat dataclass + `bfloat()` extraction helper
|
||||
- [x] WledOutputTarget, HALightOutputTarget, HALightMapping — brightness/transition
|
||||
- [x] All 15 CSS source types — smoothing, sensitivity, intensity, scale, speed, etc.
|
||||
- [x] API schemas + routes updated
|
||||
- [x] output_target_store create/update
|
||||
- [x] processor_manager add_target / add_ha_light_target
|
||||
### Configuration
|
||||
- `ha_source_id: str` — HA connection entity (EntitySelect picker)
|
||||
- `entity_id: str` — HA entity (EntitySelect picker, populated from `/api/v1/home-assistant/sources/{id}/entities`)
|
||||
- `attribute: str` — optional attribute name (text input or dropdown populated from entity attributes)
|
||||
- `min_ha_value: float` — raw HA value corresponding to output 0.0
|
||||
- `max_ha_value: float` — raw HA value corresponding to output 1.0
|
||||
- `smoothing: float` — EMA smoothing factor (0..1)
|
||||
- `return_type: "float"` — always float
|
||||
|
||||
### Phase 2: Runtime Resolution
|
||||
### Backend
|
||||
|
||||
- [x] WledTargetProcessor — BindableFloat brightness, acquire/release value streams
|
||||
- [x] HALightTargetProcessor — BindableFloat brightness + transition
|
||||
- [x] All CSS streams use `bfloat()` to extract static values from BindableFloat properties
|
||||
- [x] scene_activator — brightness_changed flag
|
||||
- [x] ColorStripStream base class — `resolve()`, `set_value_stream()`, `remove_value_stream()`
|
||||
- [x] ColorStripStreamManager — `_bind_value_streams()` / `_release_value_streams()` on acquire/release
|
||||
- [x] All stream hot loops call `self.resolve(prop, static)` for dynamic runtime binding
|
||||
- [x] KeyColorsColorStripStream — fixed to inherit from ColorStripStream
|
||||
- [ ] **Storage model** — `HAEntityValueSource` subclass in `storage/value_source.py`
|
||||
- Fields: `ha_source_id`, `entity_id`, `attribute`, `min_ha_value`, `max_ha_value`, `smoothing`
|
||||
- Register in `_VALUE_SOURCE_MAP` as `"ha_entity"`
|
||||
- `to_dict()` / `from_dict()` / `_parse_common_fields()`
|
||||
|
||||
### Phase 3: Frontend
|
||||
- [ ] **Store** — add `"ha_entity"` case in `ValueSourceStore.create_source()` and `update_source()`
|
||||
- Validate: `ha_source_id` must be non-empty, `entity_id` must be non-empty
|
||||
|
||||
- [x] TypeScript BindableFloat type + `bindableValue()` / `bindableSourceId()` helpers
|
||||
- [x] targets.ts, ha-light-targets.ts, color-strips.ts — save/load/display
|
||||
- [x] Graph connections — value source edges for ALL bindable CSS properties
|
||||
- [x] Graph layout — edge creation for CSS + target bindable properties
|
||||
- [x] custom_components/select.py — HA integration backward compat
|
||||
- [ ] **API schemas** — `HAEntityValueSourceCreate`, `HAEntityValueSourceResponse` in `api/schemas/value_sources.py`
|
||||
- Add to `ValueSourceCreate` / `ValueSourceResponse` discriminated unions
|
||||
- Fields: `ha_source_id`, `entity_id`, `attribute` (optional), `min_ha_value`, `max_ha_value`, `smoothing`
|
||||
|
||||
### Phase 4: BindableScalarWidget
|
||||
- [ ] **API routes** — add `HAEntityValueSource` → response builder in `_RESPONSE_MAP`
|
||||
|
||||
- [x] `core/bindable-scalar.ts` — reusable widget (slider + VS picker toggle)
|
||||
- [x] CSS styles (`.bindable-toggle`, `.bindable-slider-row`, `.bindable-vs-row`)
|
||||
- [x] All 11 CSS editor sliders converted (smoothing, sensitivity, intensity, scale, speed, wind, temp_influence, timeout)
|
||||
- [x] HTML templates updated with container divs
|
||||
- [ ] **Stream** — `HAEntityValueStream` in `core/processing/value_stream.py`
|
||||
- `start()`: acquire HA runtime via `ha_manager.acquire(ha_source_id)`
|
||||
- `get_value()`: read `ha_manager.get_state(ha_source_id, entity_id)` → extract state or attribute → clamp/normalize to [0,1] via min/max range → apply EMA smoothing
|
||||
- `stop()`: release HA runtime
|
||||
- `update_source()`: hot-update parameters
|
||||
- Add to `ValueStreamManager._create_stream()`
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] **TypeScript type** — `HAEntityValueSource` interface in `types.ts`
|
||||
- `source_type: 'ha_entity'`, `return_type: 'float'`
|
||||
- Fields: `ha_source_id`, `entity_id`, `attribute`, `min_ha_value`, `max_ha_value`, `smoothing`
|
||||
- Add to `ValueSourceType` union and `ValueSource` union
|
||||
|
||||
- [ ] **Icon** — add `ha_entity: _svg(P.home)` to `_valueSourceTypeIcons` in `icons.ts`
|
||||
|
||||
- [ ] **i18n** — add keys in `en.json`:
|
||||
- `value_source.type.ha_entity`: "Home Assistant Entity"
|
||||
- `value_source.type.ha_entity.desc`: "Reads value from a Home Assistant sensor or entity attribute"
|
||||
- `value_source.ha_source`: "HA Connection:"
|
||||
- `value_source.entity_id`: "Entity:"
|
||||
- `value_source.attribute`: "Attribute (optional):"
|
||||
- `value_source.min_ha_value`: "Min HA Value:"
|
||||
- `value_source.max_ha_value`: "Max HA Value:"
|
||||
|
||||
- [ ] **Editor modal** — add `ha_entity` section to `value-source-editor.html`
|
||||
- HA connection selector (EntitySelect from HA sources cache)
|
||||
- Entity selector (EntitySelect populated from HA entities endpoint)
|
||||
- Attribute text input (optional)
|
||||
- Min/Max HA value range inputs
|
||||
- Smoothing slider
|
||||
|
||||
- [ ] **Editor logic** — add `ha_entity` handler in `value-sources.ts`
|
||||
- `onValueSourceTypeChange()`: show/hide ha_entity section
|
||||
- `_typeHandlers['ha_entity']`: load/reset/getPayload
|
||||
- EntitySelect for HA source + EntitySelect for entity (refreshes when HA source changes)
|
||||
- Auto-name: "{entity_friendly_name}" or "{entity_id}"
|
||||
|
||||
- [ ] **Card renderer** — show HA source link + entity ID + attribute (if set) + range
|
||||
- [ ] **VS_TYPE_KEYS** — add `'ha_entity'` to the array
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Lerp Color Value Source (`gradient_map`)
|
||||
|
||||
A color value source that maps a numeric value source's output through a color gradient. Given a float value source (0..1), interpolates the color at that position in a user-defined gradient.
|
||||
|
||||
### Configuration
|
||||
- `value_source_id: str` — reference to a float-returning value source (EntitySelect)
|
||||
- `stops: List[ColorStop]` — gradient color stops `[{position: float, color: [R,G,B]}]` (reuse existing `ColorStop` model from color strip sources)
|
||||
- `easing: str` — interpolation mode: "linear", "step" (reuse existing easing modes)
|
||||
- `return_type: "color"` — always color
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ] **Storage model** — `GradientMapValueSource` subclass in `storage/value_source.py`
|
||||
- Fields: `value_source_id`, `stops` (list of dicts with `position` + `color`), `easing`
|
||||
- Register in `_VALUE_SOURCE_MAP` as `"gradient_map"`
|
||||
|
||||
- [ ] **Store** — add `"gradient_map"` case in `create_source()` / `update_source()`
|
||||
- Validate: at least 2 stops, `value_source_id` non-empty
|
||||
|
||||
- [ ] **API schemas** — `GradientMapValueSourceCreate`, `GradientMapValueSourceResponse`
|
||||
- Reuse `ColorStop` schema from color_strip_sources schemas (or define minimal version)
|
||||
- Add to discriminated unions
|
||||
|
||||
- [ ] **API routes** — add to `_RESPONSE_MAP`
|
||||
|
||||
- [ ] **Stream** — `GradientMapValueStream` in `value_stream.py`
|
||||
- `start()`: acquire the referenced value stream via `ValueStreamManager.acquire(value_source_id)`
|
||||
- `get_value()`: return BT.601 luminance of current color
|
||||
- `get_color()`: call `inner_stream.get_value()` → interpolate through gradient stops → return RGB tuple
|
||||
- Reuse `_compute_gradient_colors()` logic from color_strip_stream.py (or a shared helper for single-point interpolation)
|
||||
- `stop()`: release inner value stream
|
||||
- `update_source()`: hot-update stops/easing, re-acquire if value_source_id changed
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] **TypeScript type** — `GradientMapValueSource` interface
|
||||
- `source_type: 'gradient_map'`, `return_type: 'color'`
|
||||
- Fields: `value_source_id`, `stops: ColorStop[]`, `easing`
|
||||
|
||||
- [ ] **Icon** — add `gradient_map: _svg(P.rainbow)` to `_valueSourceTypeIcons`
|
||||
|
||||
- [ ] **i18n** — add keys:
|
||||
- `value_source.type.gradient_map`: "Gradient Map"
|
||||
- `value_source.type.gradient_map.desc`: "Maps a numeric value through a color gradient"
|
||||
- `value_source.input_source`: "Input Value Source:"
|
||||
- `value_source.gradient_stops`: "Gradient:"
|
||||
- `value_source.easing`: "Interpolation:"
|
||||
|
||||
- [ ] **Editor modal** — add `gradient_map` section
|
||||
- Value source selector (EntitySelect from float value sources)
|
||||
- Gradient stop editor (reuse gradient stop UI from CSS editor if possible, or build minimal version: list of position + color picker rows)
|
||||
- Easing selector (IconSelect: linear, step)
|
||||
- Live gradient preview bar (CSS linear-gradient from stops)
|
||||
|
||||
- [ ] **Editor logic** — `_typeHandlers['gradient_map']`: load/reset/getPayload
|
||||
- [ ] **Card renderer** — CSS gradient preview bar + input source link + stop count
|
||||
- [ ] **VS_TYPE_KEYS** — add `'gradient_map'`
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: CSS Extraction Color Value Source (`css_extract`)
|
||||
|
||||
A color value source that extracts a single color from a color strip source by averaging a range of LEDs. Useful for deriving a single color signal from an existing color strip.
|
||||
|
||||
### Configuration
|
||||
- `color_strip_source_id: str` — reference to a color strip source (EntitySelect)
|
||||
- `led_start: int` — start of LED range (0-based, optional, default 0)
|
||||
- `led_end: int` — end of LED range (exclusive, optional, default -1 = whole strip)
|
||||
- `return_type: "color"` — always color
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ] **Storage model** — `CSSExtractValueSource` subclass in `storage/value_source.py`
|
||||
- Fields: `color_strip_source_id`, `led_start`, `led_end`
|
||||
- Register as `"css_extract"`
|
||||
|
||||
- [ ] **Store** — add `"css_extract"` case in `create_source()` / `update_source()`
|
||||
- Validate: `color_strip_source_id` non-empty
|
||||
|
||||
- [ ] **API schemas** — `CSSExtractValueSourceCreate`, `CSSExtractValueSourceResponse`
|
||||
- Add to discriminated unions
|
||||
|
||||
- [ ] **API routes** — add to `_RESPONSE_MAP`
|
||||
|
||||
- [ ] **Stream** — `CSSExtractValueStream` in `value_stream.py`
|
||||
- `start()`: acquire color strip stream via `ColorStripStreamManager.acquire(color_strip_source_id, led_count=needed)`
|
||||
- `get_color()`: read strip colors → average the specified LED range → return single RGB tuple
|
||||
- `get_value()`: BT.601 luminance of extracted color
|
||||
- `stop()`: release color strip stream
|
||||
- `update_source()`: hot-update range, re-acquire if source changed
|
||||
- **Note**: Needs access to `ColorStripStreamManager` — may need to inject it into `ValueStreamManager` or pass via constructor
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] **TypeScript type** — `CSSExtractValueSource` interface
|
||||
- `source_type: 'css_extract'`, `return_type: 'color'`
|
||||
- Fields: `color_strip_source_id`, `led_start`, `led_end`
|
||||
|
||||
- [ ] **Icon** — add `css_extract: _svg(P.eyedropper)` (or `P.pipette` if available, else `P.palette`)
|
||||
|
||||
- [ ] **i18n** — add keys:
|
||||
- `value_source.type.css_extract`: "Strip Color Extract"
|
||||
- `value_source.type.css_extract.desc`: "Extracts a single color from a color strip source"
|
||||
- `value_source.color_strip_source`: "Color Strip Source:"
|
||||
- `value_source.led_start`: "LED Start:"
|
||||
- `value_source.led_end`: "LED End (-1 = all):"
|
||||
|
||||
- [ ] **Editor modal** — add `css_extract` section
|
||||
- Color strip source selector (EntitySelect from color strip sources cache)
|
||||
- LED start/end numeric inputs
|
||||
- Optional: live color preview swatch
|
||||
|
||||
- [ ] **Editor logic** — `_typeHandlers['css_extract']`: load/reset/getPayload
|
||||
- [ ] **Card renderer** — color strip source link + LED range badge
|
||||
- [ ] **VS_TYPE_KEYS** — add `'css_extract'`
|
||||
|
||||
---
|
||||
|
||||
## Feature 4: Value Source Type Filter in Icon Grid
|
||||
|
||||
Add a filter/category system to the value source type IconSelect so users can filter by return type or category.
|
||||
|
||||
### Implementation
|
||||
|
||||
- [ ] **Add filter tabs** above the value source type icon grid in the editor modal
|
||||
- "All" (default) — show all types
|
||||
- "Float" — show float-returning types: static, animated, audio, adaptive_time, adaptive_scene, daylight, ha_entity
|
||||
- "Color" — show color-returning types: static_color, animated_color, adaptive_time_color, gradient_map, css_extract
|
||||
|
||||
- [ ] **IconSelect enhancement** — either:
|
||||
- Option A: Add `groups` support to IconSelect (items grouped by category with filter tabs)
|
||||
- Option B: Filter `VS_TYPE_KEYS` before building items, with toggle buttons above the grid
|
||||
- Decision: Option B is simpler and follows existing patterns — add filter buttons that rebuild the icon grid
|
||||
|
||||
- [ ] **i18n** — add keys:
|
||||
- `value_source.filter.all`: "All"
|
||||
- `value_source.filter.float`: "Float"
|
||||
- `value_source.filter.color`: "Color"
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Feature 4** (filter) — smallest, unblocks better UX for the growing type list
|
||||
2. **Feature 1** (ha_entity) — standalone float type, no cross-dependencies
|
||||
3. **Feature 3** (css_extract) — needs ColorStripStreamManager injection
|
||||
4. **Feature 2** (gradient_map) — needs float VS reference + gradient UI
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
- All new types need entries in `_VALUE_SOURCE_MAP` (backend) and `VS_TYPE_KEYS` (frontend)
|
||||
- All new types need `_RESPONSE_MAP` entries in routes
|
||||
- All new types need `ValueStreamManager._create_stream()` factory case
|
||||
- All new types need icon in `_valueSourceTypeIcons`
|
||||
- All new types need i18n keys in `en.json` (and `ru.json`, `zh.json` — can defer translations)
|
||||
- `ValueSourceStore` referential integrity check on delete should verify new references (ha_entity → ha_source, gradient_map → value_source, css_extract → color_strip_source)
|
||||
- Graph editor: new edge types for ha_entity → HA source node, gradient_map → value source node, css_extract → color strip node
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Audio source routes: CRUD for audio sources + real-time test WebSocket."""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
@@ -19,8 +19,16 @@ from wled_controller.api.schemas.audio_sources import (
|
||||
AudioSourceListResponse,
|
||||
AudioSourceResponse,
|
||||
AudioSourceUpdate,
|
||||
BandExtractAudioSourceResponse,
|
||||
MonoAudioSourceResponse,
|
||||
MultichannelAudioSourceResponse,
|
||||
)
|
||||
from wled_controller.storage.audio_source import (
|
||||
AudioSource,
|
||||
BandExtractAudioSource,
|
||||
MonoAudioSource,
|
||||
MultichannelAudioSource,
|
||||
)
|
||||
from wled_controller.storage.audio_source import AudioSource
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.utils import get_logger
|
||||
@@ -31,31 +39,68 @@ logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
_RESPONSE_MAP = {
|
||||
MultichannelAudioSource: lambda s: MultichannelAudioSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
device_index=s.device_index,
|
||||
is_loopback=s.is_loopback,
|
||||
audio_template_id=s.audio_template_id,
|
||||
),
|
||||
MonoAudioSource: lambda s: MonoAudioSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
audio_source_id=s.audio_source_id,
|
||||
channel=s.channel,
|
||||
),
|
||||
BandExtractAudioSource: lambda s: BandExtractAudioSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
audio_source_id=s.audio_source_id,
|
||||
band=s.band,
|
||||
freq_low=s.freq_low,
|
||||
freq_high=s.freq_high,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _to_response(source: AudioSource) -> AudioSourceResponse:
|
||||
"""Convert an AudioSource to an AudioSourceResponse."""
|
||||
return AudioSourceResponse(
|
||||
id=source.id,
|
||||
name=source.name,
|
||||
source_type=source.source_type,
|
||||
device_index=getattr(source, "device_index", None),
|
||||
is_loopback=getattr(source, "is_loopback", None),
|
||||
audio_template_id=getattr(source, "audio_template_id", None),
|
||||
audio_source_id=getattr(source, "audio_source_id", None),
|
||||
channel=getattr(source, "channel", None),
|
||||
band=getattr(source, "band", None),
|
||||
freq_low=getattr(source, "freq_low", None),
|
||||
freq_high=getattr(source, "freq_high", None),
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
"""Convert an AudioSource dataclass to the matching response schema."""
|
||||
builder = _RESPONSE_MAP.get(type(source))
|
||||
if builder is None:
|
||||
# Fallback for unknown types — return as multichannel
|
||||
return MultichannelAudioSourceResponse(
|
||||
id=source.id,
|
||||
name=source.name,
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
device_index=getattr(source, "device_index", -1),
|
||||
is_loopback=getattr(source, "is_loopback", True),
|
||||
audio_template_id=getattr(source, "audio_template_id", None),
|
||||
)
|
||||
return builder(source)
|
||||
|
||||
|
||||
@router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"])
|
||||
async def list_audio_sources(
|
||||
_auth: AuthRequired,
|
||||
source_type: Optional[str] = Query(None, description="Filter by source_type: multichannel, mono, or band_extract"),
|
||||
source_type: Optional[str] = Query(
|
||||
None, description="Filter by source_type: multichannel, mono, or band_extract"
|
||||
),
|
||||
store: AudioSourceStore = Depends(get_audio_source_store),
|
||||
):
|
||||
"""List all audio sources, optionally filtered by type."""
|
||||
@@ -68,27 +113,26 @@ async def list_audio_sources(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/audio-sources", response_model=AudioSourceResponse, status_code=201, tags=["Audio Sources"])
|
||||
@router.post(
|
||||
"/api/v1/audio-sources",
|
||||
response_model=AudioSourceResponse,
|
||||
status_code=201,
|
||||
tags=["Audio Sources"],
|
||||
)
|
||||
async def create_audio_source(
|
||||
data: AudioSourceCreate,
|
||||
data: Annotated[AudioSourceCreate, Body(discriminator="source_type")],
|
||||
_auth: AuthRequired,
|
||||
store: AudioSourceStore = Depends(get_audio_source_store),
|
||||
):
|
||||
"""Create a new audio source."""
|
||||
try:
|
||||
fields = data.model_dump(exclude={"source_type", "name", "description", "tags"})
|
||||
source = store.create_source(
|
||||
name=data.name,
|
||||
source_type=data.source_type,
|
||||
device_index=data.device_index,
|
||||
is_loopback=data.is_loopback,
|
||||
audio_source_id=data.audio_source_id,
|
||||
channel=data.channel,
|
||||
description=data.description,
|
||||
audio_template_id=data.audio_template_id,
|
||||
tags=data.tags,
|
||||
band=data.band,
|
||||
freq_low=data.freq_low,
|
||||
freq_high=data.freq_high,
|
||||
**fields,
|
||||
)
|
||||
fire_entity_event("audio_source", "created", source.id)
|
||||
return _to_response(source)
|
||||
@@ -99,7 +143,9 @@ async def create_audio_source(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
|
||||
@router.get(
|
||||
"/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"]
|
||||
)
|
||||
async def get_audio_source(
|
||||
source_id: str,
|
||||
_auth: AuthRequired,
|
||||
@@ -113,29 +159,19 @@ async def get_audio_source(
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
|
||||
@router.put(
|
||||
"/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"]
|
||||
)
|
||||
async def update_audio_source(
|
||||
source_id: str,
|
||||
data: AudioSourceUpdate,
|
||||
data: Annotated[AudioSourceUpdate, Body(discriminator="source_type")],
|
||||
_auth: AuthRequired,
|
||||
store: AudioSourceStore = Depends(get_audio_source_store),
|
||||
):
|
||||
"""Update an existing audio source."""
|
||||
try:
|
||||
source = store.update_source(
|
||||
source_id=source_id,
|
||||
name=data.name,
|
||||
device_index=data.device_index,
|
||||
is_loopback=data.is_loopback,
|
||||
audio_source_id=data.audio_source_id,
|
||||
channel=data.channel,
|
||||
description=data.description,
|
||||
audio_template_id=data.audio_template_id,
|
||||
tags=data.tags,
|
||||
band=data.band,
|
||||
freq_low=data.freq_low,
|
||||
freq_high=data.freq_high,
|
||||
)
|
||||
fields = data.model_dump(exclude={"source_type"}, exclude_none=True)
|
||||
source = store.update_source(source_id=source_id, **fields)
|
||||
fire_entity_event("audio_source", "updated", source_id)
|
||||
return _to_response(source)
|
||||
except EntityNotFoundError as e:
|
||||
@@ -156,11 +192,13 @@ async def delete_audio_source(
|
||||
try:
|
||||
# Check if any CSS entities reference this audio source
|
||||
from wled_controller.storage.color_strip_source import AudioColorStripSource
|
||||
|
||||
for css in css_store.get_all_sources():
|
||||
if isinstance(css, AudioColorStripSource) and getattr(css, "audio_source_id", None) == source_id:
|
||||
raise ValueError(
|
||||
f"Cannot delete: referenced by color strip source '{css.name}'"
|
||||
)
|
||||
if (
|
||||
isinstance(css, AudioColorStripSource)
|
||||
and getattr(css, "audio_source_id", None) == source_id
|
||||
):
|
||||
raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'")
|
||||
|
||||
store.delete_source(source_id)
|
||||
fire_entity_event("audio_source", "deleted", source_id)
|
||||
@@ -187,6 +225,7 @@ async def test_audio_source_ws(
|
||||
snapshots as JSON at ~20 Hz.
|
||||
"""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
@@ -211,6 +250,7 @@ async def test_audio_source_ws(
|
||||
band_mask = None
|
||||
if resolved.freq_low is not None and resolved.freq_high is not None:
|
||||
from wled_controller.core.audio.band_filter import compute_band_mask
|
||||
|
||||
band_mask = compute_band_mask(resolved.freq_low, resolved.freq_high)
|
||||
|
||||
# Resolve template → engine_type + config
|
||||
@@ -257,15 +297,18 @@ async def test_audio_source_ws(
|
||||
# Apply band filter if present
|
||||
if band_mask is not None:
|
||||
from wled_controller.core.audio.band_filter import apply_band_filter
|
||||
|
||||
spectrum, rms = apply_band_filter(spectrum, rms, band_mask)
|
||||
|
||||
await websocket.send_json({
|
||||
"spectrum": spectrum.tolist(),
|
||||
"rms": round(rms, 4),
|
||||
"peak": round(analysis.peak, 4),
|
||||
"beat": analysis.beat,
|
||||
"beat_intensity": round(analysis.beat_intensity, 4),
|
||||
})
|
||||
await websocket.send_json(
|
||||
{
|
||||
"spectrum": spectrum.tolist(),
|
||||
"rms": round(rms, 4),
|
||||
"peak": round(analysis.peak, 4),
|
||||
"beat": analysis.beat,
|
||||
"beat_intensity": round(analysis.beat_intensity, 4),
|
||||
}
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
except WebSocketDisconnect:
|
||||
|
||||
@@ -4,9 +4,10 @@ import asyncio
|
||||
import json as _json
|
||||
import time as _time
|
||||
import uuid as _uuid
|
||||
from typing import Annotated
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
@@ -20,13 +21,30 @@ from wled_controller.api.dependencies import (
|
||||
get_template_store,
|
||||
)
|
||||
from wled_controller.api.schemas.color_strip_sources import (
|
||||
ApiInputCSSResponse,
|
||||
AudioCSSResponse,
|
||||
CandlelightCSSResponse,
|
||||
ColorCycleCSSResponse,
|
||||
ColorPushRequest,
|
||||
ColorStop as ColorStopSchema,
|
||||
ColorStripSourceCreate,
|
||||
ColorStripSourceListResponse,
|
||||
ColorStripSourceResponse,
|
||||
ColorStripSourceUpdate,
|
||||
CompositeCSSResponse,
|
||||
CSSCalibrationTestRequest,
|
||||
DaylightCSSResponse,
|
||||
EffectCSSResponse,
|
||||
GradientCSSResponse,
|
||||
KeyColorsCSSResponse,
|
||||
MappedCSSResponse,
|
||||
NotificationCSSResponse,
|
||||
NotifyRequest,
|
||||
PictureAdvancedCSSResponse,
|
||||
PictureCSSResponse,
|
||||
ProcessedCSSResponse,
|
||||
StaticCSSResponse,
|
||||
WeatherCSSResponse,
|
||||
)
|
||||
from wled_controller.api.schemas.devices import (
|
||||
Calibration as CalibrationSchema,
|
||||
@@ -34,15 +52,27 @@ from wled_controller.api.schemas.devices import (
|
||||
)
|
||||
from wled_controller.core.capture.calibration import (
|
||||
calibration_from_dict,
|
||||
calibration_to_dict,
|
||||
)
|
||||
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage.color_strip_source import (
|
||||
AdvancedPictureColorStripSource,
|
||||
ApiInputColorStripSource,
|
||||
AudioColorStripSource,
|
||||
CandlelightColorStripSource,
|
||||
ColorCycleColorStripSource,
|
||||
CompositeColorStripSource,
|
||||
DaylightColorStripSource,
|
||||
EffectColorStripSource,
|
||||
GradientColorStripSource,
|
||||
KeyColorsColorStripSource,
|
||||
MappedColorStripSource,
|
||||
NotificationColorStripSource,
|
||||
PictureColorStripSource,
|
||||
ProcessedColorStripSource,
|
||||
StaticColorStripSource,
|
||||
WeatherColorStripSource,
|
||||
)
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
@@ -61,50 +91,179 @@ logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
|
||||
"""Convert a ColorStripSource to a ColorStripSourceResponse.
|
||||
|
||||
Uses the source's to_dict() for type-specific fields, then applies
|
||||
schema conversions for calibration and gradient stops.
|
||||
"""
|
||||
from wled_controller.api.schemas.color_strip_sources import ColorStop as ColorStopSchema
|
||||
|
||||
d = source.to_dict()
|
||||
|
||||
# Convert calibration dict → schema object
|
||||
calibration = None
|
||||
raw_cal = d.pop("calibration", None)
|
||||
if raw_cal and isinstance(raw_cal, dict):
|
||||
calibration = CalibrationSchema(**raw_cal)
|
||||
|
||||
# Convert stop dicts → schema objects
|
||||
raw_stops = d.pop("stops", None)
|
||||
stops = None
|
||||
if raw_stops is not None:
|
||||
try:
|
||||
stops = [ColorStopSchema(**s) for s in raw_stops]
|
||||
except Exception:
|
||||
stops = None
|
||||
|
||||
# Remove serialized timestamp strings — use actual datetime objects
|
||||
d.pop("created_at", None)
|
||||
d.pop("updated_at", None)
|
||||
|
||||
# Filter to only keys accepted by the schema (to_dict may include extra
|
||||
# fields like 'fps' that aren't in the response model)
|
||||
valid_fields = ColorStripSourceResponse.model_fields
|
||||
filtered = {k: v for k, v in d.items() if k in valid_fields}
|
||||
|
||||
return ColorStripSourceResponse(
|
||||
**filtered,
|
||||
calibration=calibration,
|
||||
stops=stops,
|
||||
def _common_response_kwargs(source, overlay_active: bool = False) -> dict:
|
||||
"""Shared response fields from any ColorStripSource."""
|
||||
return dict(
|
||||
id=source.id,
|
||||
name=source.name,
|
||||
description=source.description,
|
||||
led_count=getattr(source, "led_count", 0),
|
||||
overlay_active=overlay_active,
|
||||
clock_id=source.clock_id,
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _calibration_schema(source) -> CalibrationSchema | None:
|
||||
"""Convert a source's calibration to a schema object, or None."""
|
||||
cal = getattr(source, "calibration", None)
|
||||
if cal is None:
|
||||
return None
|
||||
try:
|
||||
return CalibrationSchema(**calibration_to_dict(cal))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _stops_schema(source) -> list[ColorStopSchema] | None:
|
||||
"""Convert a source's stops list to schema objects, or None."""
|
||||
raw = getattr(source, "stops", None)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return [ColorStopSchema(**dict(s)) for s in raw]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# Maps storage class → response builder lambda.
|
||||
_RESPONSE_MAP: dict = {
|
||||
PictureColorStripSource: lambda s, kw: PictureCSSResponse(
|
||||
**kw,
|
||||
picture_source_id=s.picture_source_id,
|
||||
smoothing=s.smoothing.to_dict(),
|
||||
interpolation_mode=s.interpolation_mode,
|
||||
calibration=_calibration_schema(s),
|
||||
),
|
||||
AdvancedPictureColorStripSource: lambda s, kw: PictureAdvancedCSSResponse(
|
||||
**kw,
|
||||
smoothing=s.smoothing.to_dict(),
|
||||
interpolation_mode=s.interpolation_mode,
|
||||
calibration=_calibration_schema(s),
|
||||
),
|
||||
StaticColorStripSource: lambda s, kw: StaticCSSResponse(
|
||||
**kw,
|
||||
color=s.color.to_dict(),
|
||||
animation=s.animation,
|
||||
),
|
||||
GradientColorStripSource: lambda s, kw: GradientCSSResponse(
|
||||
**kw,
|
||||
stops=_stops_schema(s),
|
||||
animation=s.animation,
|
||||
easing=s.easing,
|
||||
gradient_id=s.gradient_id,
|
||||
),
|
||||
ColorCycleColorStripSource: lambda s, kw: ColorCycleCSSResponse(
|
||||
**kw,
|
||||
colors=[list(c) for c in s.colors],
|
||||
),
|
||||
EffectColorStripSource: lambda s, kw: EffectCSSResponse(
|
||||
**kw,
|
||||
effect_type=s.effect_type,
|
||||
palette=s.palette,
|
||||
gradient_id=s.gradient_id,
|
||||
color=s.color.to_dict(),
|
||||
intensity=s.intensity.to_dict(),
|
||||
scale=s.scale.to_dict(),
|
||||
mirror=s.mirror,
|
||||
custom_palette=s.custom_palette,
|
||||
),
|
||||
CompositeColorStripSource: lambda s, kw: CompositeCSSResponse(
|
||||
**kw,
|
||||
layers=[dict(layer) for layer in s.layers],
|
||||
),
|
||||
MappedColorStripSource: lambda s, kw: MappedCSSResponse(
|
||||
**kw,
|
||||
zones=[dict(z) for z in s.zones],
|
||||
),
|
||||
AudioColorStripSource: lambda s, kw: AudioCSSResponse(
|
||||
**kw,
|
||||
visualization_mode=s.visualization_mode,
|
||||
audio_source_id=s.audio_source_id,
|
||||
sensitivity=s.sensitivity.to_dict(),
|
||||
smoothing=s.smoothing.to_dict(),
|
||||
palette=s.palette,
|
||||
gradient_id=s.gradient_id,
|
||||
color=s.color.to_dict(),
|
||||
color_peak=s.color_peak.to_dict(),
|
||||
mirror=s.mirror,
|
||||
),
|
||||
ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse(
|
||||
**kw,
|
||||
fallback_color=s.fallback_color.to_dict(),
|
||||
timeout=s.timeout.to_dict(),
|
||||
interpolation=s.interpolation,
|
||||
),
|
||||
NotificationColorStripSource: lambda s, kw: NotificationCSSResponse(
|
||||
**kw,
|
||||
notification_effect=s.notification_effect,
|
||||
duration_ms=s.duration_ms.to_dict(),
|
||||
default_color=s.default_color.to_dict(),
|
||||
app_colors=dict(s.app_colors),
|
||||
app_filter_mode=s.app_filter_mode,
|
||||
app_filter_list=list(s.app_filter_list),
|
||||
os_listener=s.os_listener,
|
||||
sound_asset_id=s.sound_asset_id,
|
||||
sound_volume=s.sound_volume.to_dict(),
|
||||
app_sounds=dict(s.app_sounds),
|
||||
),
|
||||
DaylightColorStripSource: lambda s, kw: DaylightCSSResponse(
|
||||
**kw,
|
||||
speed=s.speed.to_dict(),
|
||||
use_real_time=s.use_real_time,
|
||||
latitude=s.latitude,
|
||||
longitude=s.longitude,
|
||||
),
|
||||
CandlelightColorStripSource: lambda s, kw: CandlelightCSSResponse(
|
||||
**kw,
|
||||
color=s.color.to_dict(),
|
||||
intensity=s.intensity.to_dict(),
|
||||
num_candles=s.num_candles,
|
||||
speed=s.speed.to_dict(),
|
||||
wind_strength=s.wind_strength.to_dict(),
|
||||
candle_type=s.candle_type,
|
||||
),
|
||||
ProcessedColorStripSource: lambda s, kw: ProcessedCSSResponse(
|
||||
**kw,
|
||||
input_source_id=s.input_source_id,
|
||||
processing_template_id=s.processing_template_id,
|
||||
),
|
||||
WeatherColorStripSource: lambda s, kw: WeatherCSSResponse(
|
||||
**kw,
|
||||
weather_source_id=s.weather_source_id,
|
||||
speed=s.speed.to_dict(),
|
||||
temperature_influence=s.temperature_influence.to_dict(),
|
||||
),
|
||||
KeyColorsColorStripSource: lambda s, kw: KeyColorsCSSResponse(
|
||||
**kw,
|
||||
picture_source_id=s.picture_source_id,
|
||||
rectangles=[r.to_dict() for r in s.rectangles],
|
||||
interpolation_mode=s.interpolation_mode,
|
||||
smoothing=s.smoothing.to_dict(),
|
||||
brightness=s.brightness.to_dict(),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
|
||||
"""Convert a ColorStripSource to the matching per-type response schema."""
|
||||
kw = _common_response_kwargs(source, overlay_active)
|
||||
builder = _RESPONSE_MAP.get(type(source))
|
||||
if builder is None:
|
||||
# Fallback: use to_dict() and build a PictureCSSResponse
|
||||
logger.warning("No response builder for %s, falling back", type(source).__name__)
|
||||
return PictureCSSResponse(
|
||||
**kw,
|
||||
picture_source_id="",
|
||||
smoothing=0.3,
|
||||
interpolation_mode="average",
|
||||
calibration=None,
|
||||
)
|
||||
return builder(source, kw)
|
||||
|
||||
|
||||
def _resolve_display_index(
|
||||
picture_source_id: str, picture_source_store: PictureSourceStore, depth: int = 0
|
||||
) -> int:
|
||||
@@ -150,22 +309,37 @@ def _extract_css_kwargs(data) -> dict:
|
||||
Converts nested Pydantic models (calibration, stops, layers, zones,
|
||||
animation) to plain dicts/lists that the store expects.
|
||||
"""
|
||||
kwargs = data.model_dump(
|
||||
exclude_unset=False, exclude={"calibration", "stops", "layers", "zones", "animation"}
|
||||
)
|
||||
# Remove fields that don't map to store kwargs
|
||||
kwargs.pop("source_type", None)
|
||||
# Exclude nested models that need special conversion
|
||||
exclude_fields = {"source_type"}
|
||||
for nested in ("calibration", "stops", "layers", "zones", "animation"):
|
||||
if hasattr(data, nested):
|
||||
exclude_fields.add(nested)
|
||||
kwargs = data.model_dump(exclude_unset=False, exclude=exclude_fields)
|
||||
|
||||
# Convert nested Pydantic models → plain dicts for the store
|
||||
if hasattr(data, "calibration"):
|
||||
cal = getattr(data, "calibration", None)
|
||||
if cal is not None:
|
||||
kwargs["calibration"] = calibration_from_dict(cal.model_dump())
|
||||
else:
|
||||
kwargs["calibration"] = None
|
||||
|
||||
if hasattr(data, "stops"):
|
||||
stops = getattr(data, "stops", None)
|
||||
kwargs["stops"] = [s.model_dump() for s in stops] if stops is not None else None
|
||||
|
||||
if hasattr(data, "layers"):
|
||||
layers = getattr(data, "layers", None)
|
||||
kwargs["layers"] = [layer.model_dump() for layer in layers] if layers is not None else None
|
||||
|
||||
if hasattr(data, "zones"):
|
||||
zones = getattr(data, "zones", None)
|
||||
kwargs["zones"] = [z.model_dump() for z in zones] if zones is not None else None
|
||||
|
||||
if hasattr(data, "animation"):
|
||||
anim = getattr(data, "animation", None)
|
||||
kwargs["animation"] = anim.model_dump() if anim else None
|
||||
|
||||
if data.calibration is not None:
|
||||
kwargs["calibration"] = calibration_from_dict(data.calibration.model_dump())
|
||||
else:
|
||||
kwargs["calibration"] = None
|
||||
kwargs["stops"] = [s.model_dump() for s in data.stops] if data.stops is not None else None
|
||||
kwargs["layers"] = (
|
||||
[layer.model_dump() for layer in data.layers] if data.layers is not None else None
|
||||
)
|
||||
kwargs["zones"] = [z.model_dump() for z in data.zones] if data.zones is not None else None
|
||||
kwargs["animation"] = data.animation.model_dump() if data.animation else None
|
||||
return kwargs
|
||||
|
||||
|
||||
@@ -176,7 +350,7 @@ def _extract_css_kwargs(data) -> dict:
|
||||
status_code=201,
|
||||
)
|
||||
async def create_color_strip_source(
|
||||
data: ColorStripSourceCreate,
|
||||
data: Annotated[ColorStripSourceCreate, Body(discriminator="source_type")],
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripStore = Depends(get_color_strip_store),
|
||||
):
|
||||
@@ -223,7 +397,7 @@ async def get_color_strip_source(
|
||||
)
|
||||
async def update_color_strip_source(
|
||||
source_id: str,
|
||||
data: ColorStripSourceUpdate,
|
||||
data: Annotated[ColorStripSourceUpdate, Body(discriminator="source_type")],
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripStore = Depends(get_color_strip_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Output target routes: CRUD endpoints and batch state/metrics queries."""
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi import APIRouter, Body, HTTPException, Depends
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
@@ -12,6 +13,9 @@ from wled_controller.api.dependencies import (
|
||||
get_processor_manager,
|
||||
)
|
||||
from wled_controller.api.schemas.output_targets import (
|
||||
HALightMappingSchema,
|
||||
HALightOutputTargetResponse,
|
||||
LedOutputTargetResponse,
|
||||
OutputTargetCreate,
|
||||
OutputTargetListResponse,
|
||||
OutputTargetResponse,
|
||||
@@ -25,7 +29,6 @@ from wled_controller.storage.ha_light_output_target import (
|
||||
HALightMapping,
|
||||
HALightOutputTarget,
|
||||
)
|
||||
from wled_controller.api.schemas.output_targets import HALightMappingSchema
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
@@ -35,58 +38,68 @@ logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse:
|
||||
"""Convert a WledOutputTarget to LedOutputTargetResponse."""
|
||||
return LedOutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
device_id=target.device_id,
|
||||
color_strip_source_id=target.color_strip_source_id,
|
||||
brightness=target.brightness.to_dict(),
|
||||
fps=target.fps.to_dict(),
|
||||
keepalive_interval=target.keepalive_interval,
|
||||
state_check_interval=target.state_check_interval,
|
||||
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||
adaptive_fps=target.adaptive_fps,
|
||||
protocol=target.protocol,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _ha_light_target_to_response(
|
||||
target: HALightOutputTarget,
|
||||
) -> HALightOutputTargetResponse:
|
||||
"""Convert an HALightOutputTarget to HALightOutputTargetResponse."""
|
||||
return HALightOutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
ha_source_id=target.ha_source_id,
|
||||
color_strip_source_id=target.color_strip_source_id,
|
||||
brightness=target.brightness.to_dict(),
|
||||
ha_light_mappings=[
|
||||
HALightMappingSchema(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=m.brightness_scale.to_dict(),
|
||||
)
|
||||
for m in target.light_mappings
|
||||
],
|
||||
update_rate=target.update_rate.to_dict(),
|
||||
transition=target.transition.to_dict(),
|
||||
color_tolerance=target.color_tolerance.to_dict(),
|
||||
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _target_to_response(target) -> OutputTargetResponse:
|
||||
"""Convert an OutputTarget to OutputTargetResponse."""
|
||||
"""Convert any OutputTarget to the appropriate typed response."""
|
||||
if isinstance(target, WledOutputTarget):
|
||||
return OutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
target_type=target.target_type,
|
||||
device_id=target.device_id,
|
||||
color_strip_source_id=target.color_strip_source_id,
|
||||
brightness=target.brightness.to_dict(),
|
||||
fps=target.fps.to_dict(),
|
||||
keepalive_interval=target.keepalive_interval,
|
||||
state_check_interval=target.state_check_interval,
|
||||
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||
adaptive_fps=target.adaptive_fps,
|
||||
protocol=target.protocol,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
return _led_target_to_response(target)
|
||||
elif isinstance(target, HALightOutputTarget):
|
||||
return OutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
target_type=target.target_type,
|
||||
ha_source_id=target.ha_source_id,
|
||||
color_strip_source_id=target.color_strip_source_id,
|
||||
brightness=target.brightness.to_dict(),
|
||||
ha_light_mappings=[
|
||||
HALightMappingSchema(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=m.brightness_scale.to_dict(),
|
||||
)
|
||||
for m in target.light_mappings
|
||||
],
|
||||
update_rate=target.update_rate.to_dict(),
|
||||
transition=target.transition.to_dict(),
|
||||
color_tolerance=target.color_tolerance.to_dict(),
|
||||
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
return _ha_light_target_to_response(target)
|
||||
else:
|
||||
return OutputTargetResponse(
|
||||
# Fallback for unknown types — use LED response with defaults
|
||||
return LedOutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
target_type=target.target_type,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
created_at=target.created_at,
|
||||
@@ -101,7 +114,7 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
|
||||
)
|
||||
async def create_target(
|
||||
data: OutputTargetCreate,
|
||||
data: Annotated[OutputTargetCreate, Body(discriminator="target_type")],
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
@@ -110,12 +123,14 @@ async def create_target(
|
||||
"""Create a new output target."""
|
||||
try:
|
||||
# Validate device exists if provided
|
||||
if data.device_id:
|
||||
device_id = getattr(data, "device_id", "")
|
||||
if device_id:
|
||||
try:
|
||||
device_store.get_device(data.device_id)
|
||||
device_store.get_device(device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
|
||||
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
|
||||
ha_mappings = (
|
||||
[
|
||||
HALightMapping(
|
||||
@@ -124,9 +139,9 @@ async def create_target(
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
)
|
||||
for m in data.ha_light_mappings
|
||||
for m in ha_light_mappings_raw
|
||||
]
|
||||
if data.ha_light_mappings
|
||||
if ha_light_mappings_raw
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -134,22 +149,22 @@ async def create_target(
|
||||
target = target_store.create_target(
|
||||
name=data.name,
|
||||
target_type=data.target_type,
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
brightness=data.brightness,
|
||||
fps=data.fps,
|
||||
keepalive_interval=data.keepalive_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
adaptive_fps=data.adaptive_fps,
|
||||
protocol=data.protocol,
|
||||
device_id=device_id,
|
||||
color_strip_source_id=getattr(data, "color_strip_source_id", ""),
|
||||
brightness=getattr(data, "brightness", 1.0),
|
||||
fps=getattr(data, "fps", 30),
|
||||
keepalive_interval=getattr(data, "keepalive_interval", 1.0),
|
||||
state_check_interval=getattr(data, "state_check_interval", 30),
|
||||
min_brightness_threshold=getattr(data, "min_brightness_threshold", 0),
|
||||
adaptive_fps=getattr(data, "adaptive_fps", False),
|
||||
protocol=getattr(data, "protocol", "ddp"),
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
ha_source_id=data.ha_source_id,
|
||||
ha_source_id=getattr(data, "ha_source_id", ""),
|
||||
ha_light_mappings=ha_mappings,
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
color_tolerance=data.color_tolerance,
|
||||
update_rate=getattr(data, "update_rate", 2.0),
|
||||
transition=getattr(data, "transition", 0.5),
|
||||
color_tolerance=getattr(data, "color_tolerance", 5),
|
||||
)
|
||||
|
||||
# Register in processor manager
|
||||
@@ -223,7 +238,7 @@ async def get_target(
|
||||
)
|
||||
async def update_target(
|
||||
target_id: str,
|
||||
data: OutputTargetUpdate,
|
||||
data: Annotated[OutputTargetUpdate, Body(discriminator="target_type")],
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
@@ -232,15 +247,17 @@ async def update_target(
|
||||
"""Update a output target."""
|
||||
try:
|
||||
# Validate device exists if changing
|
||||
if data.device_id is not None and data.device_id:
|
||||
device_id = getattr(data, "device_id", None)
|
||||
if device_id is not None and device_id:
|
||||
try:
|
||||
device_store.get_device(data.device_id)
|
||||
device_store.get_device(device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
|
||||
# Build HA light mappings if provided
|
||||
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
|
||||
ha_mappings = None
|
||||
if data.ha_light_mappings is not None:
|
||||
if ha_light_mappings_raw is not None:
|
||||
ha_mappings = [
|
||||
HALightMapping(
|
||||
entity_id=m.entity_id,
|
||||
@@ -248,57 +265,68 @@ async def update_target(
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
)
|
||||
for m in data.ha_light_mappings
|
||||
for m in ha_light_mappings_raw
|
||||
]
|
||||
|
||||
# Update in store
|
||||
target = target_store.update_target(
|
||||
target_id=target_id,
|
||||
name=data.name,
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
brightness=data.brightness,
|
||||
fps=data.fps,
|
||||
keepalive_interval=data.keepalive_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
adaptive_fps=data.adaptive_fps,
|
||||
protocol=data.protocol,
|
||||
device_id=device_id,
|
||||
color_strip_source_id=getattr(data, "color_strip_source_id", None),
|
||||
brightness=getattr(data, "brightness", None),
|
||||
fps=getattr(data, "fps", None),
|
||||
keepalive_interval=getattr(data, "keepalive_interval", None),
|
||||
state_check_interval=getattr(data, "state_check_interval", None),
|
||||
min_brightness_threshold=getattr(data, "min_brightness_threshold", None),
|
||||
adaptive_fps=getattr(data, "adaptive_fps", None),
|
||||
protocol=getattr(data, "protocol", None),
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
ha_source_id=data.ha_source_id,
|
||||
ha_source_id=getattr(data, "ha_source_id", None),
|
||||
ha_light_mappings=ha_mappings,
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
color_tolerance=data.color_tolerance,
|
||||
update_rate=getattr(data, "update_rate", None),
|
||||
transition=getattr(data, "transition", None),
|
||||
color_tolerance=getattr(data, "color_tolerance", None),
|
||||
)
|
||||
|
||||
# Sync processor manager (run in thread — css release/acquire can block)
|
||||
color_strip_source_id = getattr(data, "color_strip_source_id", None)
|
||||
fps = getattr(data, "fps", None)
|
||||
keepalive_interval = getattr(data, "keepalive_interval", None)
|
||||
state_check_interval = getattr(data, "state_check_interval", None)
|
||||
min_brightness_threshold = getattr(data, "min_brightness_threshold", None)
|
||||
adaptive_fps = getattr(data, "adaptive_fps", None)
|
||||
update_rate = getattr(data, "update_rate", None)
|
||||
transition = getattr(data, "transition", None)
|
||||
color_tolerance = getattr(data, "color_tolerance", None)
|
||||
brightness = getattr(data, "brightness", None)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
target.sync_with_manager,
|
||||
manager,
|
||||
settings_changed=(
|
||||
data.fps is not None
|
||||
or data.keepalive_interval is not None
|
||||
or data.state_check_interval is not None
|
||||
or data.min_brightness_threshold is not None
|
||||
or data.adaptive_fps is not None
|
||||
or data.update_rate is not None
|
||||
or data.transition is not None
|
||||
or data.color_tolerance is not None
|
||||
or data.ha_light_mappings is not None
|
||||
or data.brightness is not None
|
||||
fps is not None
|
||||
or keepalive_interval is not None
|
||||
or state_check_interval is not None
|
||||
or min_brightness_threshold is not None
|
||||
or adaptive_fps is not None
|
||||
or update_rate is not None
|
||||
or transition is not None
|
||||
or color_tolerance is not None
|
||||
or ha_light_mappings_raw is not None
|
||||
or brightness is not None
|
||||
),
|
||||
css_changed=data.color_strip_source_id is not None,
|
||||
brightness_changed=data.brightness is not None,
|
||||
css_changed=color_strip_source_id is not None,
|
||||
brightness_changed=brightness is not None,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
|
||||
pass
|
||||
|
||||
# Device change requires async stop -> swap -> start cycle
|
||||
if data.device_id is not None:
|
||||
if device_id is not None:
|
||||
try:
|
||||
await manager.update_target_device(target_id, target.device_id)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
from fastapi import APIRouter, Body, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import Response
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
@@ -29,6 +30,10 @@ from wled_controller.api.schemas.picture_sources import (
|
||||
PictureSourceResponse,
|
||||
PictureSourceTestRequest,
|
||||
PictureSourceUpdate,
|
||||
ProcessedPictureSourceResponse,
|
||||
RawPictureSourceResponse,
|
||||
StaticImagePictureSourceResponse,
|
||||
VideoPictureSourceResponse,
|
||||
)
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
from wled_controller.core.filters import FilterRegistry, ImagePool
|
||||
@@ -36,7 +41,12 @@ from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource, VideoCaptureSource
|
||||
from wled_controller.storage.picture_source import (
|
||||
ProcessedPictureSource,
|
||||
ScreenCapturePictureSource,
|
||||
StaticImagePictureSource,
|
||||
VideoCaptureSource,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
@@ -45,34 +55,67 @@ logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _stream_to_response(s) -> PictureSourceResponse:
|
||||
"""Convert a PictureSource to its API response."""
|
||||
return PictureSourceResponse(
|
||||
_RESPONSE_MAP = {
|
||||
ScreenCapturePictureSource: lambda s: RawPictureSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
stream_type=s.stream_type,
|
||||
display_index=getattr(s, "display_index", None),
|
||||
capture_template_id=getattr(s, "capture_template_id", None),
|
||||
target_fps=getattr(s, "target_fps", None),
|
||||
source_stream_id=getattr(s, "source_stream_id", None),
|
||||
postprocessing_template_id=getattr(s, "postprocessing_template_id", None),
|
||||
image_asset_id=getattr(s, "image_asset_id", None),
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
# Video fields
|
||||
video_asset_id=getattr(s, "video_asset_id", None),
|
||||
loop=getattr(s, "loop", None),
|
||||
playback_speed=getattr(s, "playback_speed", None),
|
||||
start_time=getattr(s, "start_time", None),
|
||||
end_time=getattr(s, "end_time", None),
|
||||
resolution_limit=getattr(s, "resolution_limit", None),
|
||||
clock_id=getattr(s, "clock_id", None),
|
||||
)
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
display_index=s.display_index,
|
||||
capture_template_id=s.capture_template_id,
|
||||
target_fps=s.target_fps,
|
||||
),
|
||||
ProcessedPictureSource: lambda s: ProcessedPictureSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
source_stream_id=s.source_stream_id,
|
||||
postprocessing_template_id=s.postprocessing_template_id,
|
||||
),
|
||||
StaticImagePictureSource: lambda s: StaticImagePictureSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
image_asset_id=s.image_asset_id,
|
||||
),
|
||||
VideoCaptureSource: lambda s: VideoPictureSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
video_asset_id=s.video_asset_id,
|
||||
loop=s.loop,
|
||||
playback_speed=s.playback_speed,
|
||||
start_time=s.start_time,
|
||||
end_time=s.end_time,
|
||||
resolution_limit=s.resolution_limit,
|
||||
clock_id=s.clock_id,
|
||||
target_fps=s.target_fps,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v1/picture-sources", response_model=PictureSourceListResponse, tags=["Picture Sources"])
|
||||
def _stream_to_response(s) -> PictureSourceResponse:
|
||||
"""Convert a PictureSource storage model to the matching response schema."""
|
||||
builder = _RESPONSE_MAP.get(type(s))
|
||||
if builder is None:
|
||||
raise ValueError(f"Unknown picture source type: {type(s).__name__}")
|
||||
return builder(s)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/picture-sources", response_model=PictureSourceListResponse, tags=["Picture Sources"]
|
||||
)
|
||||
async def list_picture_sources(
|
||||
_auth: AuthRequired,
|
||||
store: PictureSourceStore = Depends(get_picture_source_store),
|
||||
@@ -87,7 +130,11 @@ async def list_picture_sources(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/api/v1/picture-sources/validate-image", response_model=ImageValidateResponse, tags=["Picture Sources"])
|
||||
@router.post(
|
||||
"/api/v1/picture-sources/validate-image",
|
||||
response_model=ImageValidateResponse,
|
||||
tags=["Picture Sources"],
|
||||
)
|
||||
async def validate_image(
|
||||
data: ImageValidateRequest,
|
||||
_auth: AuthRequired,
|
||||
@@ -120,6 +167,7 @@ async def validate_image(
|
||||
load_image_file,
|
||||
thumbnail as make_thumbnail,
|
||||
)
|
||||
|
||||
if isinstance(src, bytes):
|
||||
image = load_image_bytes(src)
|
||||
else:
|
||||
@@ -131,12 +179,12 @@ async def validate_image(
|
||||
|
||||
width, height, preview = await asyncio.to_thread(_process_image, img_bytes)
|
||||
|
||||
return ImageValidateResponse(
|
||||
valid=True, width=width, height=height, preview=preview
|
||||
)
|
||||
return ImageValidateResponse(valid=True, width=width, height=height, preview=preview)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
return ImageValidateResponse(valid=False, error=f"HTTP {e.response.status_code}: {e.response.reason_phrase}")
|
||||
return ImageValidateResponse(
|
||||
valid=False, error=f"HTTP {e.response.status_code}: {e.response.reason_phrase}"
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
return ImageValidateResponse(valid=False, error=f"Request failed: {e}")
|
||||
except Exception as e:
|
||||
@@ -166,7 +214,12 @@ async def get_full_image(
|
||||
img_bytes = path
|
||||
|
||||
def _encode_full(src):
|
||||
from wled_controller.utils.image_codec import encode_jpeg, load_image_bytes, load_image_file
|
||||
from wled_controller.utils.image_codec import (
|
||||
encode_jpeg,
|
||||
load_image_bytes,
|
||||
load_image_file,
|
||||
)
|
||||
|
||||
if isinstance(src, bytes):
|
||||
image = load_image_bytes(src)
|
||||
else:
|
||||
@@ -182,9 +235,14 @@ async def get_full_image(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/picture-sources", response_model=PictureSourceResponse, tags=["Picture Sources"], status_code=201)
|
||||
@router.post(
|
||||
"/api/v1/picture-sources",
|
||||
response_model=PictureSourceResponse,
|
||||
tags=["Picture Sources"],
|
||||
status_code=201,
|
||||
)
|
||||
async def create_picture_source(
|
||||
data: PictureSourceCreate,
|
||||
data: Annotated[PictureSourceCreate, Body(discriminator="stream_type")],
|
||||
_auth: AuthRequired,
|
||||
store: PictureSourceStore = Depends(get_picture_source_store),
|
||||
template_store: TemplateStore = Depends(get_template_store),
|
||||
@@ -211,25 +269,13 @@ async def create_picture_source(
|
||||
detail=f"Postprocessing template not found: {data.postprocessing_template_id}",
|
||||
)
|
||||
|
||||
fields = data.model_dump(exclude={"stream_type", "name", "description", "tags"})
|
||||
stream = store.create_stream(
|
||||
name=data.name,
|
||||
stream_type=data.stream_type,
|
||||
display_index=data.display_index,
|
||||
capture_template_id=data.capture_template_id,
|
||||
target_fps=data.target_fps,
|
||||
source_stream_id=data.source_stream_id,
|
||||
postprocessing_template_id=data.postprocessing_template_id,
|
||||
image_asset_id=data.image_asset_id,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
# Video fields
|
||||
video_asset_id=data.video_asset_id,
|
||||
loop=data.loop,
|
||||
playback_speed=data.playback_speed,
|
||||
start_time=data.start_time,
|
||||
end_time=data.end_time,
|
||||
resolution_limit=data.resolution_limit,
|
||||
clock_id=data.clock_id,
|
||||
**fields,
|
||||
)
|
||||
fire_entity_event("picture_source", "created", stream.id)
|
||||
return _stream_to_response(stream)
|
||||
@@ -245,7 +291,11 @@ async def create_picture_source(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"])
|
||||
@router.get(
|
||||
"/api/v1/picture-sources/{stream_id}",
|
||||
response_model=PictureSourceResponse,
|
||||
tags=["Picture Sources"],
|
||||
)
|
||||
async def get_picture_source(
|
||||
stream_id: str,
|
||||
_auth: AuthRequired,
|
||||
@@ -259,35 +309,21 @@ async def get_picture_source(
|
||||
raise HTTPException(status_code=404, detail=f"Picture source {stream_id} not found")
|
||||
|
||||
|
||||
@router.put("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"])
|
||||
@router.put(
|
||||
"/api/v1/picture-sources/{stream_id}",
|
||||
response_model=PictureSourceResponse,
|
||||
tags=["Picture Sources"],
|
||||
)
|
||||
async def update_picture_source(
|
||||
stream_id: str,
|
||||
data: PictureSourceUpdate,
|
||||
data: Annotated[PictureSourceUpdate, Body(discriminator="stream_type")],
|
||||
_auth: AuthRequired,
|
||||
store: PictureSourceStore = Depends(get_picture_source_store),
|
||||
):
|
||||
"""Update a picture source."""
|
||||
try:
|
||||
stream = store.update_stream(
|
||||
stream_id=stream_id,
|
||||
name=data.name,
|
||||
display_index=data.display_index,
|
||||
capture_template_id=data.capture_template_id,
|
||||
target_fps=data.target_fps,
|
||||
source_stream_id=data.source_stream_id,
|
||||
postprocessing_template_id=data.postprocessing_template_id,
|
||||
image_asset_id=data.image_asset_id,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
# Video fields
|
||||
video_asset_id=data.video_asset_id,
|
||||
loop=data.loop,
|
||||
playback_speed=data.playback_speed,
|
||||
start_time=data.start_time,
|
||||
end_time=data.end_time,
|
||||
resolution_limit=data.resolution_limit,
|
||||
clock_id=data.clock_id,
|
||||
)
|
||||
fields = data.model_dump(exclude={"stream_type"}, exclude_none=True)
|
||||
stream = store.update_stream(stream_id=stream_id, **fields)
|
||||
fire_entity_event("picture_source", "updated", stream_id)
|
||||
return _stream_to_response(stream)
|
||||
except EntityNotFoundError as e:
|
||||
@@ -316,7 +352,7 @@ async def delete_picture_source(
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. "
|
||||
"Please reassign those targets before deleting.",
|
||||
"Please reassign those targets before deleting.",
|
||||
)
|
||||
store.delete_stream(stream_id)
|
||||
fire_entity_event("picture_source", "deleted", stream_id)
|
||||
@@ -350,8 +386,11 @@ async def get_video_thumbnail(
|
||||
|
||||
# Resolve video asset to file path
|
||||
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
|
||||
|
||||
asset_store = _get_asset_store()
|
||||
video_path = asset_store.get_file_path(source.video_asset_id) if source.video_asset_id else None
|
||||
video_path = (
|
||||
asset_store.get_file_path(source.video_asset_id) if source.video_asset_id else None
|
||||
)
|
||||
if not video_path:
|
||||
raise HTTPException(status_code=400, detail="Video asset not found or missing file")
|
||||
|
||||
@@ -375,7 +414,11 @@ async def get_video_thumbnail(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"])
|
||||
@router.post(
|
||||
"/api/v1/picture-sources/{stream_id}/test",
|
||||
response_model=TemplateTestResponse,
|
||||
tags=["Picture Sources"],
|
||||
)
|
||||
async def test_picture_source(
|
||||
stream_id: str,
|
||||
test_request: PictureSourceTestRequest,
|
||||
@@ -410,7 +453,11 @@ async def test_picture_source(
|
||||
from wled_controller.utils.image_codec import load_image_file
|
||||
|
||||
asset_store = _get_asset_store()
|
||||
image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
|
||||
image_path = (
|
||||
asset_store.get_file_path(raw_stream.image_asset_id)
|
||||
if raw_stream.image_asset_id
|
||||
else None
|
||||
)
|
||||
if not image_path:
|
||||
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
|
||||
|
||||
@@ -460,7 +507,9 @@ async def test_picture_source(
|
||||
frame_count = 1
|
||||
last_frame = screen_capture
|
||||
else:
|
||||
logger.info(f"Starting {test_request.capture_duration}s stream test for {stream_id}")
|
||||
logger.info(
|
||||
f"Starting {test_request.capture_duration}s stream test for {stream_id}"
|
||||
)
|
||||
end_time = start_time + test_request.capture_duration
|
||||
while time.perf_counter() < end_time:
|
||||
capture_start = time.perf_counter()
|
||||
@@ -482,7 +531,10 @@ async def test_picture_source(
|
||||
image = last_frame.image
|
||||
|
||||
# Create thumbnail + encode (CPU-bound — run in thread)
|
||||
from wled_controller.utils.image_codec import encode_jpeg_data_uri, thumbnail as make_thumbnail
|
||||
from wled_controller.utils.image_codec import (
|
||||
encode_jpeg_data_uri,
|
||||
thumbnail as make_thumbnail,
|
||||
)
|
||||
|
||||
pp_template_ids = chain["postprocessing_template_ids"]
|
||||
flat_filters = None
|
||||
@@ -491,13 +543,16 @@ async def test_picture_source(
|
||||
pp_template = pp_store.get_template(pp_template_ids[0])
|
||||
flat_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
|
||||
except ValueError:
|
||||
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
|
||||
logger.warning(
|
||||
f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview"
|
||||
)
|
||||
|
||||
def _create_thumbnails_and_encode(img, filters):
|
||||
thumb = make_thumbnail(img, 640)
|
||||
|
||||
if filters:
|
||||
pool = ImagePool()
|
||||
|
||||
def apply_filters(arr):
|
||||
for fi in filters:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
@@ -505,6 +560,7 @@ async def test_picture_source(
|
||||
if result is not None:
|
||||
arr = result
|
||||
return arr
|
||||
|
||||
thumb = apply_filters(thumb)
|
||||
img = apply_filters(img)
|
||||
|
||||
@@ -513,8 +569,8 @@ async def test_picture_source(
|
||||
th, tw = thumb.shape[:2]
|
||||
return tw, th, thumb_uri, full_uri
|
||||
|
||||
thumbnail_width, thumbnail_height, thumbnail_data_uri, full_data_uri = await asyncio.to_thread(
|
||||
_create_thumbnails_and_encode, image, flat_filters
|
||||
thumbnail_width, thumbnail_height, thumbnail_data_uri, full_data_uri = (
|
||||
await asyncio.to_thread(_create_thumbnails_and_encode, image, flat_filters)
|
||||
)
|
||||
|
||||
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||
@@ -610,7 +666,11 @@ async def test_picture_source_ws(
|
||||
from wled_controller.api.dependencies import get_asset_store as _get_asset_store2
|
||||
|
||||
asset_store = _get_asset_store2()
|
||||
video_path = asset_store.get_file_path(raw_stream.video_asset_id) if raw_stream.video_asset_id else None
|
||||
video_path = (
|
||||
asset_store.get_file_path(raw_stream.video_asset_id)
|
||||
if raw_stream.video_asset_id
|
||||
else None
|
||||
)
|
||||
if not video_path:
|
||||
await websocket.close(code=4004, reason="Video asset not found or missing file")
|
||||
return
|
||||
@@ -631,6 +691,7 @@ async def test_picture_source_ws(
|
||||
def _encode_video_frame(image, pw):
|
||||
"""Encode numpy RGB image as JPEG base64 data URI."""
|
||||
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
|
||||
|
||||
if pw:
|
||||
image = resize_down(image, pw)
|
||||
h, w = image.shape[:2]
|
||||
@@ -639,6 +700,7 @@ async def test_picture_source_ws(
|
||||
try:
|
||||
await asyncio.get_event_loop().run_in_executor(None, video_stream.start)
|
||||
import time as _time
|
||||
|
||||
fps = min(raw_stream.target_fps or 30, 30)
|
||||
frame_time = 1.0 / fps
|
||||
end_at = _time.monotonic() + duration
|
||||
@@ -650,30 +712,42 @@ async def test_picture_source_ws(
|
||||
last_frame = frame
|
||||
frame_count += 1
|
||||
thumb, w, h = await asyncio.get_event_loop().run_in_executor(
|
||||
None, _encode_video_frame, frame.image, preview_width or None,
|
||||
None,
|
||||
_encode_video_frame,
|
||||
frame.image,
|
||||
preview_width or None,
|
||||
)
|
||||
elapsed = duration - (end_at - _time.monotonic())
|
||||
await websocket.send_json({
|
||||
"type": "frame",
|
||||
"thumbnail": thumb,
|
||||
"width": w, "height": h,
|
||||
"elapsed": round(elapsed, 1),
|
||||
"frame_count": frame_count,
|
||||
})
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "frame",
|
||||
"thumbnail": thumb,
|
||||
"width": w,
|
||||
"height": h,
|
||||
"elapsed": round(elapsed, 1),
|
||||
"frame_count": frame_count,
|
||||
}
|
||||
)
|
||||
await asyncio.sleep(frame_time)
|
||||
# Send final result
|
||||
if last_frame is not None:
|
||||
full_img, fw, fh = await asyncio.get_event_loop().run_in_executor(
|
||||
None, _encode_video_frame, last_frame.image, None,
|
||||
None,
|
||||
_encode_video_frame,
|
||||
last_frame.image,
|
||||
None,
|
||||
)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "result",
|
||||
"full_image": full_img,
|
||||
"width": fw,
|
||||
"height": fh,
|
||||
"total_frames": frame_count,
|
||||
"duration": duration,
|
||||
"avg_fps": round(frame_count / max(duration, 0.001), 1),
|
||||
}
|
||||
)
|
||||
await websocket.send_json({
|
||||
"type": "result",
|
||||
"full_image": full_img,
|
||||
"width": fw, "height": fh,
|
||||
"total_frames": frame_count,
|
||||
"duration": duration,
|
||||
"avg_fps": round(frame_count / max(duration, 0.001), 1),
|
||||
})
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("Video source test WebSocket disconnected for %s", stream_id)
|
||||
pass
|
||||
@@ -701,7 +775,9 @@ async def test_picture_source_ws(
|
||||
return
|
||||
|
||||
if capture_template.engine_type not in EngineRegistry.get_available_engines():
|
||||
await websocket.close(code=4003, reason=f"Engine '{capture_template.engine_type}' not available")
|
||||
await websocket.close(
|
||||
code=4003, reason=f"Engine '{capture_template.engine_type}' not available"
|
||||
)
|
||||
return
|
||||
|
||||
# Resolve postprocessing filters (if any)
|
||||
@@ -731,7 +807,9 @@ async def test_picture_source_ws(
|
||||
|
||||
try:
|
||||
await stream_capture_test(
|
||||
websocket, engine_factory, duration,
|
||||
websocket,
|
||||
engine_factory,
|
||||
duration,
|
||||
pp_filters=pp_filters,
|
||||
preview_width=preview_width or None,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Value source routes: CRUD for value sources."""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
@@ -13,12 +13,37 @@ from wled_controller.api.dependencies import (
|
||||
get_value_source_store,
|
||||
)
|
||||
from wled_controller.api.schemas.value_sources import (
|
||||
AdaptiveSceneValueSourceResponse,
|
||||
AdaptiveTimeColorValueSourceResponse,
|
||||
AdaptiveTimeValueSourceResponse,
|
||||
AnimatedColorValueSourceResponse,
|
||||
AnimatedValueSourceResponse,
|
||||
AudioValueSourceResponse,
|
||||
CSSExtractValueSourceResponse,
|
||||
DaylightValueSourceResponse,
|
||||
GradientMapValueSourceResponse,
|
||||
HAEntityValueSourceResponse,
|
||||
StaticColorValueSourceResponse,
|
||||
StaticValueSourceResponse,
|
||||
ValueSourceCreate,
|
||||
ValueSourceListResponse,
|
||||
ValueSourceResponse,
|
||||
ValueSourceUpdate,
|
||||
)
|
||||
from wled_controller.storage.value_source import ValueSource
|
||||
from wled_controller.storage.value_source import (
|
||||
AdaptiveTimeColorValueSource,
|
||||
AdaptiveValueSource,
|
||||
AnimatedColorValueSource,
|
||||
AnimatedValueSource,
|
||||
AudioValueSource,
|
||||
CSSExtractValueSource,
|
||||
DaylightValueSource,
|
||||
GradientMapValueSource,
|
||||
HAEntityValueSource,
|
||||
StaticColorValueSource,
|
||||
StaticValueSource,
|
||||
ValueSource,
|
||||
)
|
||||
from wled_controller.storage.value_source_store import ValueSourceStore
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
@@ -29,40 +54,178 @@ logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Maps storage class to the response builder for that type.
|
||||
_RESPONSE_MAP = {
|
||||
StaticValueSource: lambda s: StaticValueSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
value=s.value,
|
||||
),
|
||||
AnimatedValueSource: lambda s: AnimatedValueSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
waveform=s.waveform,
|
||||
speed=s.speed,
|
||||
min_value=s.min_value,
|
||||
max_value=s.max_value,
|
||||
),
|
||||
AudioValueSource: lambda s: AudioValueSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
audio_source_id=s.audio_source_id,
|
||||
mode=s.mode,
|
||||
sensitivity=s.sensitivity,
|
||||
smoothing=s.smoothing,
|
||||
min_value=s.min_value,
|
||||
max_value=s.max_value,
|
||||
auto_gain=s.auto_gain,
|
||||
),
|
||||
DaylightValueSource: lambda s: DaylightValueSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
speed=s.speed,
|
||||
use_real_time=s.use_real_time,
|
||||
latitude=s.latitude,
|
||||
min_value=s.min_value,
|
||||
max_value=s.max_value,
|
||||
),
|
||||
StaticColorValueSource: lambda s: StaticColorValueSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
color=list(s.color),
|
||||
),
|
||||
AnimatedColorValueSource: lambda s: AnimatedColorValueSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
colors=[list(c) for c in s.colors],
|
||||
speed=s.speed,
|
||||
easing=s.easing,
|
||||
),
|
||||
AdaptiveTimeColorValueSource: lambda s: AdaptiveTimeColorValueSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
schedule=s.schedule,
|
||||
),
|
||||
HAEntityValueSource: lambda s: HAEntityValueSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
ha_source_id=s.ha_source_id,
|
||||
entity_id=s.entity_id,
|
||||
attribute=s.attribute,
|
||||
min_ha_value=s.min_ha_value,
|
||||
max_ha_value=s.max_ha_value,
|
||||
smoothing=s.smoothing,
|
||||
),
|
||||
GradientMapValueSource: lambda s: GradientMapValueSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
value_source_id=s.value_source_id,
|
||||
gradient_id=s.gradient_id,
|
||||
easing=s.easing,
|
||||
),
|
||||
CSSExtractValueSource: lambda s: CSSExtractValueSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
color_strip_source_id=s.color_strip_source_id,
|
||||
led_start=s.led_start,
|
||||
led_end=s.led_end,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _to_response(source: ValueSource) -> ValueSourceResponse:
|
||||
"""Convert a ValueSource to a ValueSourceResponse."""
|
||||
d = source.to_dict()
|
||||
return ValueSourceResponse(
|
||||
id=d["id"],
|
||||
name=d["name"],
|
||||
source_type=d["source_type"],
|
||||
value=d.get("value"),
|
||||
waveform=d.get("waveform"),
|
||||
speed=d.get("speed"),
|
||||
min_value=d.get("min_value"),
|
||||
max_value=d.get("max_value"),
|
||||
audio_source_id=d.get("audio_source_id"),
|
||||
mode=d.get("mode"),
|
||||
sensitivity=d.get("sensitivity"),
|
||||
smoothing=d.get("smoothing"),
|
||||
auto_gain=d.get("auto_gain"),
|
||||
schedule=d.get("schedule"),
|
||||
picture_source_id=d.get("picture_source_id"),
|
||||
scene_behavior=d.get("scene_behavior"),
|
||||
use_real_time=d.get("use_real_time"),
|
||||
latitude=d.get("latitude"),
|
||||
description=d.get("description"),
|
||||
tags=d.get("tags", []),
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
"""Convert a ValueSource dataclass to the matching response schema."""
|
||||
# AdaptiveValueSource covers both adaptive_time and adaptive_scene
|
||||
if isinstance(source, AdaptiveValueSource):
|
||||
if source.source_type == "adaptive_scene":
|
||||
return AdaptiveSceneValueSourceResponse(
|
||||
id=source.id,
|
||||
name=source.name,
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
picture_source_id=source.picture_source_id,
|
||||
scene_behavior=source.scene_behavior,
|
||||
sensitivity=source.sensitivity,
|
||||
smoothing=source.smoothing,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
)
|
||||
return AdaptiveTimeValueSourceResponse(
|
||||
id=source.id,
|
||||
name=source.name,
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
schedule=source.schedule,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
)
|
||||
|
||||
builder = _RESPONSE_MAP.get(type(source))
|
||||
if builder is None:
|
||||
# Fallback for unknown types — return as static
|
||||
return StaticValueSourceResponse(
|
||||
id=source.id,
|
||||
name=source.name,
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
value=getattr(source, "value", 1.0),
|
||||
)
|
||||
return builder(source)
|
||||
|
||||
|
||||
@router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"])
|
||||
async def list_value_sources(
|
||||
_auth: AuthRequired,
|
||||
source_type: Optional[str] = Query(None, description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene"),
|
||||
source_type: Optional[str] = Query(
|
||||
None,
|
||||
description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene",
|
||||
),
|
||||
store: ValueSourceStore = Depends(get_value_source_store),
|
||||
):
|
||||
"""List all value sources, optionally filtered by type."""
|
||||
@@ -75,34 +238,27 @@ async def list_value_sources(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/value-sources", response_model=ValueSourceResponse, status_code=201, tags=["Value Sources"])
|
||||
@router.post(
|
||||
"/api/v1/value-sources",
|
||||
response_model=ValueSourceResponse,
|
||||
status_code=201,
|
||||
tags=["Value Sources"],
|
||||
)
|
||||
async def create_value_source(
|
||||
data: ValueSourceCreate,
|
||||
data: Annotated[ValueSourceCreate, Body(discriminator="source_type")],
|
||||
_auth: AuthRequired,
|
||||
store: ValueSourceStore = Depends(get_value_source_store),
|
||||
):
|
||||
"""Create a new value source."""
|
||||
try:
|
||||
# Extract all fields from the discriminated union body
|
||||
fields = data.model_dump(exclude={"source_type", "name", "description", "tags"})
|
||||
source = store.create_source(
|
||||
name=data.name,
|
||||
source_type=data.source_type,
|
||||
value=data.value,
|
||||
waveform=data.waveform,
|
||||
speed=data.speed,
|
||||
min_value=data.min_value,
|
||||
max_value=data.max_value,
|
||||
audio_source_id=data.audio_source_id,
|
||||
mode=data.mode,
|
||||
sensitivity=data.sensitivity,
|
||||
smoothing=data.smoothing,
|
||||
description=data.description,
|
||||
schedule=data.schedule,
|
||||
picture_source_id=data.picture_source_id,
|
||||
scene_behavior=data.scene_behavior,
|
||||
auto_gain=data.auto_gain,
|
||||
use_real_time=data.use_real_time,
|
||||
latitude=data.latitude,
|
||||
tags=data.tags,
|
||||
**fields,
|
||||
)
|
||||
fire_entity_event("value_source", "created", source.id)
|
||||
return _to_response(source)
|
||||
@@ -113,7 +269,9 @@ async def create_value_source(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
|
||||
@router.get(
|
||||
"/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"]
|
||||
)
|
||||
async def get_value_source(
|
||||
source_id: str,
|
||||
_auth: AuthRequired,
|
||||
@@ -127,37 +285,21 @@ async def get_value_source(
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
|
||||
@router.put(
|
||||
"/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"]
|
||||
)
|
||||
async def update_value_source(
|
||||
source_id: str,
|
||||
data: ValueSourceUpdate,
|
||||
data: Annotated[ValueSourceUpdate, Body(discriminator="source_type")],
|
||||
_auth: AuthRequired,
|
||||
store: ValueSourceStore = Depends(get_value_source_store),
|
||||
pm: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Update an existing value source."""
|
||||
try:
|
||||
source = store.update_source(
|
||||
source_id=source_id,
|
||||
name=data.name,
|
||||
value=data.value,
|
||||
waveform=data.waveform,
|
||||
speed=data.speed,
|
||||
min_value=data.min_value,
|
||||
max_value=data.max_value,
|
||||
audio_source_id=data.audio_source_id,
|
||||
mode=data.mode,
|
||||
sensitivity=data.sensitivity,
|
||||
smoothing=data.smoothing,
|
||||
description=data.description,
|
||||
schedule=data.schedule,
|
||||
picture_source_id=data.picture_source_id,
|
||||
scene_behavior=data.scene_behavior,
|
||||
auto_gain=data.auto_gain,
|
||||
use_real_time=data.use_real_time,
|
||||
latitude=data.latitude,
|
||||
tags=data.tags,
|
||||
)
|
||||
# Extract all fields, excluding None values and the discriminator
|
||||
fields = data.model_dump(exclude={"source_type"}, exclude_none=True)
|
||||
source = store.update_source(source_id=source_id, **fields)
|
||||
# Hot-reload running value streams
|
||||
pm.update_value_source(source_id)
|
||||
fire_entity_event("value_source", "updated", source_id)
|
||||
@@ -180,12 +322,11 @@ async def delete_value_source(
|
||||
try:
|
||||
# Check if any targets reference this value source
|
||||
from wled_controller.storage.wled_output_target import WledOutputTarget
|
||||
|
||||
for target in target_store.get_all_targets():
|
||||
if isinstance(target, WledOutputTarget):
|
||||
if getattr(target, "brightness_value_source_id", "") == source_id:
|
||||
raise ValueError(
|
||||
f"Cannot delete: referenced by target '{target.name}'"
|
||||
)
|
||||
raise ValueError(f"Cannot delete: referenced by target '{target.name}'")
|
||||
|
||||
store.delete_source(source_id)
|
||||
fire_entity_event("value_source", "deleted", source_id)
|
||||
@@ -211,6 +352,7 @@ async def test_value_source_ws(
|
||||
and streams {value: float} JSON to the client.
|
||||
"""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
@@ -1,67 +1,149 @@
|
||||
"""Audio source schemas (CRUD)."""
|
||||
"""Audio source schemas — discriminated unions per source type."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Literal, Optional
|
||||
from typing import Annotated, List, Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Discriminator, Field, Tag
|
||||
|
||||
# =====================================================================
|
||||
# Response schemas (per-type, discriminated union)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class AudioSourceCreate(BaseModel):
|
||||
"""Request to create an audio source."""
|
||||
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
source_type: Literal["multichannel", "mono", "band_extract"] = Field(description="Source type")
|
||||
# multichannel fields
|
||||
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
|
||||
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
|
||||
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
|
||||
# mono fields
|
||||
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
|
||||
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
|
||||
# band_extract fields
|
||||
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
|
||||
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)", ge=20, le=20000)
|
||||
freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)", ge=20, le=20000)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class AudioSourceUpdate(BaseModel):
|
||||
"""Request to update an audio source."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
|
||||
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
|
||||
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
|
||||
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
|
||||
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
|
||||
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
|
||||
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)", ge=20, le=20000)
|
||||
freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)", ge=20, le=20000)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class AudioSourceResponse(BaseModel):
|
||||
"""Audio source response."""
|
||||
class _AudioSourceResponseBase(BaseModel):
|
||||
"""Shared fields for all audio source responses."""
|
||||
|
||||
id: str = Field(description="Source ID")
|
||||
name: str = Field(description="Source name")
|
||||
source_type: str = Field(description="Source type: multichannel, mono, or band_extract")
|
||||
device_index: Optional[int] = Field(None, description="Audio device index")
|
||||
is_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode")
|
||||
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
|
||||
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
|
||||
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
|
||||
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
|
||||
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)")
|
||||
freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
class MultichannelAudioSourceResponse(_AudioSourceResponseBase):
|
||||
source_type: Literal["multichannel"] = "multichannel"
|
||||
device_index: int = Field(description="Audio device index (-1 = default)")
|
||||
is_loopback: bool = Field(description="WASAPI loopback mode")
|
||||
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
|
||||
|
||||
|
||||
class MonoAudioSourceResponse(_AudioSourceResponseBase):
|
||||
source_type: Literal["mono"] = "mono"
|
||||
audio_source_id: str = Field(description="Parent audio source ID")
|
||||
channel: str = Field(description="Channel: mono|left|right")
|
||||
|
||||
|
||||
class BandExtractAudioSourceResponse(_AudioSourceResponseBase):
|
||||
source_type: Literal["band_extract"] = "band_extract"
|
||||
audio_source_id: str = Field(description="Parent audio source ID")
|
||||
band: str = Field(description="Band preset: bass|mid|treble|custom")
|
||||
freq_low: float = Field(description="Low frequency bound (Hz)")
|
||||
freq_high: float = Field(description="High frequency bound (Hz)")
|
||||
|
||||
|
||||
AudioSourceResponse = Annotated[
|
||||
Union[
|
||||
Annotated[MultichannelAudioSourceResponse, Tag("multichannel")],
|
||||
Annotated[MonoAudioSourceResponse, Tag("mono")],
|
||||
Annotated[BandExtractAudioSourceResponse, Tag("band_extract")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# Create schemas (per-type, discriminated union)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class _AudioSourceCreateBase(BaseModel):
|
||||
"""Shared fields for all audio source create requests."""
|
||||
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class MultichannelAudioSourceCreate(_AudioSourceCreateBase):
|
||||
source_type: Literal["multichannel"] = "multichannel"
|
||||
device_index: int = Field(-1, description="Audio device index (-1 = default)")
|
||||
is_loopback: bool = Field(True, description="True for system audio (WASAPI loopback)")
|
||||
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
|
||||
|
||||
|
||||
class MonoAudioSourceCreate(_AudioSourceCreateBase):
|
||||
source_type: Literal["mono"] = "mono"
|
||||
audio_source_id: str = Field("", description="Parent audio source ID")
|
||||
channel: str = Field("mono", description="Channel: mono|left|right")
|
||||
|
||||
|
||||
class BandExtractAudioSourceCreate(_AudioSourceCreateBase):
|
||||
source_type: Literal["band_extract"] = "band_extract"
|
||||
audio_source_id: str = Field("", description="Parent audio source ID")
|
||||
band: str = Field("bass", description="Band preset: bass|mid|treble|custom")
|
||||
freq_low: float = Field(20.0, description="Low frequency bound (Hz)", ge=20, le=20000)
|
||||
freq_high: float = Field(250.0, description="High frequency bound (Hz)", ge=20, le=20000)
|
||||
|
||||
|
||||
AudioSourceCreate = Annotated[
|
||||
Union[
|
||||
Annotated[MultichannelAudioSourceCreate, Tag("multichannel")],
|
||||
Annotated[MonoAudioSourceCreate, Tag("mono")],
|
||||
Annotated[BandExtractAudioSourceCreate, Tag("band_extract")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# Update schemas (per-type, discriminated union)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class _AudioSourceUpdateBase(BaseModel):
|
||||
"""Shared fields for all audio source update requests."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class MultichannelAudioSourceUpdate(_AudioSourceUpdateBase):
|
||||
source_type: Literal["multichannel"] = "multichannel"
|
||||
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
|
||||
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
|
||||
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
|
||||
|
||||
|
||||
class MonoAudioSourceUpdate(_AudioSourceUpdateBase):
|
||||
source_type: Literal["mono"] = "mono"
|
||||
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
|
||||
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
|
||||
|
||||
|
||||
class BandExtractAudioSourceUpdate(_AudioSourceUpdateBase):
|
||||
source_type: Literal["band_extract"] = "band_extract"
|
||||
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
|
||||
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
|
||||
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)", ge=20, le=20000)
|
||||
freq_high: Optional[float] = Field(
|
||||
None, description="High frequency bound (Hz)", ge=20, le=20000
|
||||
)
|
||||
|
||||
|
||||
AudioSourceUpdate = Annotated[
|
||||
Union[
|
||||
Annotated[MultichannelAudioSourceUpdate, Tag("multichannel")],
|
||||
Annotated[MonoAudioSourceUpdate, Tag("mono")],
|
||||
Annotated[BandExtractAudioSourceUpdate, Tag("band_extract")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# List response
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class AudioSourceListResponse(BaseModel):
|
||||
"""List of audio sources."""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
"""Output target schemas (CRUD, processing state, metrics)."""
|
||||
"""Output target schemas — discriminated unions per target type."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional, List, Union
|
||||
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Discriminator, Field, Tag
|
||||
|
||||
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
|
||||
|
||||
@@ -43,12 +43,86 @@ class HALightMappingSchema(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class OutputTargetCreate(BaseModel):
|
||||
"""Request to create an output target."""
|
||||
# =====================================================================
|
||||
# Response schemas (per-type, discriminated union)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class _OutputTargetResponseBase(BaseModel):
|
||||
"""Shared fields for all output target responses."""
|
||||
|
||||
id: str = Field(description="Target ID")
|
||||
name: str = Field(description="Target name")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
class LedOutputTargetResponse(_OutputTargetResponseBase):
|
||||
target_type: Literal["led"] = "led"
|
||||
device_id: str = Field(default="", description="LED device ID")
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
|
||||
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable)")
|
||||
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
|
||||
state_check_interval: int = Field(
|
||||
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
|
||||
)
|
||||
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||
default=0, description="Min brightness threshold (bindable, 0=disabled)"
|
||||
)
|
||||
adaptive_fps: bool = Field(
|
||||
default=False, description="Auto-reduce FPS when device is unresponsive"
|
||||
)
|
||||
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
|
||||
|
||||
|
||||
class HALightOutputTargetResponse(_OutputTargetResponseBase):
|
||||
target_type: Literal["ha_light"] = "ha_light"
|
||||
ha_source_id: str = Field(default="", description="Home Assistant source ID")
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
|
||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||
None, description="LED-to-light mappings"
|
||||
)
|
||||
update_rate: Optional[BindableFloatInput] = Field(
|
||||
None, description="Service call rate Hz (bindable)"
|
||||
)
|
||||
transition: Optional[BindableFloatInput] = Field(
|
||||
None, description="HA transition seconds (bindable)"
|
||||
)
|
||||
color_tolerance: Optional[BindableFloatInput] = Field(
|
||||
None, description="RGB delta tolerance (bindable)"
|
||||
)
|
||||
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||
default=0, description="Min brightness threshold (bindable, 0=disabled)"
|
||||
)
|
||||
|
||||
|
||||
OutputTargetResponse = Annotated[
|
||||
Union[
|
||||
Annotated[LedOutputTargetResponse, Tag("led")],
|
||||
Annotated[HALightOutputTargetResponse, Tag("ha_light")],
|
||||
],
|
||||
Discriminator("target_type"),
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# Create schemas (per-type, discriminated union)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class _OutputTargetCreateBase(BaseModel):
|
||||
"""Shared fields for all output target create requests."""
|
||||
|
||||
name: str = Field(description="Target name", min_length=1, max_length=100)
|
||||
target_type: str = Field(default="led", description="Target type (led, ha_light)")
|
||||
# LED target fields
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class LedOutputTargetCreate(_OutputTargetCreateBase):
|
||||
target_type: Literal["led"] = "led"
|
||||
device_id: str = Field(default="", description="LED device ID")
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
brightness: Optional[BindableFloatInput] = Field(
|
||||
@@ -71,7 +145,7 @@ class OutputTargetCreate(BaseModel):
|
||||
)
|
||||
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||
default=0,
|
||||
description="Min brightness threshold (bindable, 0=disabled); below this → off",
|
||||
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
|
||||
)
|
||||
adaptive_fps: bool = Field(
|
||||
default=False, description="Auto-reduce FPS when device is unresponsive"
|
||||
@@ -81,32 +155,56 @@ class OutputTargetCreate(BaseModel):
|
||||
pattern="^(ddp|http)$",
|
||||
description="Send protocol: ddp (UDP) or http (JSON API)",
|
||||
)
|
||||
# HA light target fields
|
||||
ha_source_id: str = Field(
|
||||
default="", description="Home Assistant source ID (for ha_light targets)"
|
||||
|
||||
|
||||
class HALightOutputTargetCreate(_OutputTargetCreateBase):
|
||||
target_type: Literal["ha_light"] = "ha_light"
|
||||
ha_source_id: str = Field(default="", description="Home Assistant source ID")
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
brightness: Optional[BindableFloatInput] = Field(
|
||||
default=1.0, description="Brightness (bindable)"
|
||||
)
|
||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||
None, description="LED-to-light mappings (for ha_light targets)"
|
||||
None, description="LED-to-light mappings"
|
||||
)
|
||||
update_rate: Optional[BindableFloatInput] = Field(
|
||||
default=2.0, description="Service call rate in Hz (bindable, for ha_light targets)"
|
||||
default=2.0, description="Service call rate in Hz (bindable)"
|
||||
)
|
||||
transition: Optional[BindableFloatInput] = Field(
|
||||
default=0.5, description="HA transition seconds (bindable, for ha_light targets)"
|
||||
default=0.5, description="HA transition seconds (bindable)"
|
||||
)
|
||||
color_tolerance: Optional[BindableFloatInput] = Field(
|
||||
default=5,
|
||||
description="RGB delta tolerance (bindable, for ha_light targets)",
|
||||
default=5, description="RGB delta tolerance (bindable)"
|
||||
)
|
||||
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||
default=0,
|
||||
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class OutputTargetUpdate(BaseModel):
|
||||
"""Request to update an output target."""
|
||||
OutputTargetCreate = Annotated[
|
||||
Union[
|
||||
Annotated[LedOutputTargetCreate, Tag("led")],
|
||||
Annotated[HALightOutputTargetCreate, Tag("ha_light")],
|
||||
],
|
||||
Discriminator("target_type"),
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# Update schemas (per-type, discriminated union)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class _OutputTargetUpdateBase(BaseModel):
|
||||
"""Shared fields for all output target update requests."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
|
||||
# LED target fields
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class LedOutputTargetUpdate(_OutputTargetUpdateBase):
|
||||
target_type: Literal["led"] = "led"
|
||||
device_id: Optional[str] = Field(None, description="LED device ID")
|
||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
|
||||
@@ -126,64 +224,41 @@ class OutputTargetUpdate(BaseModel):
|
||||
protocol: Optional[str] = Field(
|
||||
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
|
||||
)
|
||||
# HA light target fields
|
||||
ha_source_id: Optional[str] = Field(
|
||||
None, description="Home Assistant source ID (for ha_light targets)"
|
||||
)
|
||||
|
||||
|
||||
class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
|
||||
target_type: Literal["ha_light"] = "ha_light"
|
||||
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
|
||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
|
||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||
None, description="LED-to-light mappings (for ha_light targets)"
|
||||
None, description="LED-to-light mappings"
|
||||
)
|
||||
update_rate: Optional[BindableFloatInput] = Field(
|
||||
None, description="Service call rate Hz (bindable, for ha_light targets)"
|
||||
None, description="Service call rate Hz (bindable)"
|
||||
)
|
||||
transition: Optional[BindableFloatInput] = Field(
|
||||
None, description="HA transition seconds (bindable, for ha_light targets)"
|
||||
None, description="HA transition seconds (bindable)"
|
||||
)
|
||||
color_tolerance: Optional[BindableFloatInput] = Field(
|
||||
None, description="RGB delta tolerance (bindable, for ha_light targets)"
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class OutputTargetResponse(BaseModel):
|
||||
"""Output target response."""
|
||||
|
||||
id: str = Field(description="Target ID")
|
||||
name: str = Field(description="Target name")
|
||||
target_type: str = Field(description="Target type")
|
||||
# LED target fields
|
||||
device_id: str = Field(default="", description="LED device ID")
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
|
||||
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable)")
|
||||
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
|
||||
state_check_interval: int = Field(
|
||||
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
|
||||
None, description="RGB delta tolerance (bindable)"
|
||||
)
|
||||
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||
default=0, description="Min brightness threshold (bindable, 0=disabled)"
|
||||
None, description="Min brightness threshold (bindable, 0=disabled)"
|
||||
)
|
||||
adaptive_fps: bool = Field(
|
||||
default=False, description="Auto-reduce FPS when device is unresponsive"
|
||||
)
|
||||
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
|
||||
# HA light target fields
|
||||
ha_source_id: str = Field(default="", description="Home Assistant source ID (ha_light)")
|
||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||
None, description="LED-to-light mappings (ha_light)"
|
||||
)
|
||||
update_rate: Optional[BindableFloatInput] = Field(
|
||||
None, description="Service call rate Hz (bindable, ha_light)"
|
||||
)
|
||||
transition: Optional[BindableFloatInput] = Field(
|
||||
None, description="HA transition seconds (bindable, ha_light)"
|
||||
)
|
||||
color_tolerance: Optional[int] = Field(None, description="RGB delta tolerance (ha_light)")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
OutputTargetUpdate = Annotated[
|
||||
Union[
|
||||
Annotated[LedOutputTargetUpdate, Tag("led")],
|
||||
Annotated[HALightOutputTargetUpdate, Tag("ha_light")],
|
||||
],
|
||||
Discriminator("target_type"),
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# List response & utility schemas
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class OutputTargetListResponse(BaseModel):
|
||||
|
||||
@@ -1,80 +1,183 @@
|
||||
"""Picture source schemas."""
|
||||
"""Picture source schemas — discriminated unions per stream type."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Literal, Optional
|
||||
from typing import Annotated, List, Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Discriminator, Field, Tag
|
||||
|
||||
# =====================================================================
|
||||
# Response schemas (per-type, discriminated union)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class PictureSourceCreate(BaseModel):
|
||||
"""Request to create a picture source."""
|
||||
|
||||
name: str = Field(description="Stream name", min_length=1, max_length=100)
|
||||
stream_type: Literal["raw", "processed", "static_image", "video"] = Field(description="Stream type")
|
||||
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
|
||||
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
|
||||
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
|
||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
|
||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
||||
image_asset_id: Optional[str] = Field(None, description="Image asset ID (static_image streams)")
|
||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
# Video fields
|
||||
video_asset_id: Optional[str] = Field(None, description="Video asset ID (video streams)")
|
||||
loop: bool = Field(True, description="Loop video playback")
|
||||
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
|
||||
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
|
||||
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
|
||||
resolution_limit: Optional[int] = Field(None, description="Max width in pixels for decode downscale", ge=64, le=7680)
|
||||
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
|
||||
|
||||
|
||||
class PictureSourceUpdate(BaseModel):
|
||||
"""Request to update a picture source."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
|
||||
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
|
||||
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
|
||||
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
|
||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
|
||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
||||
image_asset_id: Optional[str] = Field(None, description="Image asset ID (static_image streams)")
|
||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
# Video fields
|
||||
video_asset_id: Optional[str] = Field(None, description="Video asset ID (video streams)")
|
||||
loop: Optional[bool] = Field(None, description="Loop video playback")
|
||||
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier", ge=0.1, le=10.0)
|
||||
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
|
||||
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
|
||||
resolution_limit: Optional[int] = Field(None, description="Max width in pixels for decode downscale", ge=64, le=7680)
|
||||
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
|
||||
|
||||
|
||||
class PictureSourceResponse(BaseModel):
|
||||
"""Picture source information response."""
|
||||
class _PictureSourceResponseBase(BaseModel):
|
||||
"""Shared fields for all picture source responses."""
|
||||
|
||||
id: str = Field(description="Stream ID")
|
||||
name: str = Field(description="Stream name")
|
||||
stream_type: str = Field(description="Stream type (raw, processed, static_image, or video)")
|
||||
display_index: Optional[int] = Field(None, description="Display index")
|
||||
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
|
||||
target_fps: Optional[int] = Field(None, description="Target FPS")
|
||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
|
||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID")
|
||||
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
|
||||
description: Optional[str] = Field(None, description="Stream description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Stream description")
|
||||
# Video fields
|
||||
|
||||
|
||||
class RawPictureSourceResponse(_PictureSourceResponseBase):
|
||||
stream_type: Literal["raw"] = "raw"
|
||||
display_index: int = Field(description="Display index")
|
||||
capture_template_id: str = Field(description="Capture template ID")
|
||||
target_fps: int = Field(description="Target FPS")
|
||||
|
||||
|
||||
class ProcessedPictureSourceResponse(_PictureSourceResponseBase):
|
||||
stream_type: Literal["processed"] = "processed"
|
||||
source_stream_id: str = Field(description="Source stream ID")
|
||||
postprocessing_template_id: str = Field(description="Postprocessing template ID")
|
||||
|
||||
|
||||
class StaticImagePictureSourceResponse(_PictureSourceResponseBase):
|
||||
stream_type: Literal["static_image"] = "static_image"
|
||||
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
|
||||
|
||||
|
||||
class VideoPictureSourceResponse(_PictureSourceResponseBase):
|
||||
stream_type: Literal["video"] = "video"
|
||||
video_asset_id: Optional[str] = Field(None, description="Video asset ID")
|
||||
loop: Optional[bool] = Field(None, description="Loop video playback")
|
||||
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier")
|
||||
loop: bool = Field(True, description="Loop video playback")
|
||||
playback_speed: float = Field(1.0, description="Playback speed multiplier")
|
||||
start_time: Optional[float] = Field(None, description="Trim start time in seconds")
|
||||
end_time: Optional[float] = Field(None, description="Trim end time in seconds")
|
||||
resolution_limit: Optional[int] = Field(None, description="Max width for decode")
|
||||
clock_id: Optional[str] = Field(None, description="Sync clock ID")
|
||||
target_fps: int = Field(30, description="Target FPS")
|
||||
|
||||
|
||||
PictureSourceResponse = Annotated[
|
||||
Union[
|
||||
Annotated[RawPictureSourceResponse, Tag("raw")],
|
||||
Annotated[ProcessedPictureSourceResponse, Tag("processed")],
|
||||
Annotated[StaticImagePictureSourceResponse, Tag("static_image")],
|
||||
Annotated[VideoPictureSourceResponse, Tag("video")],
|
||||
],
|
||||
Discriminator("stream_type"),
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# Create schemas (per-type, discriminated union)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class _PictureSourceCreateBase(BaseModel):
|
||||
"""Shared fields for all picture source create requests."""
|
||||
|
||||
name: str = Field(description="Stream name", min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class RawPictureSourceCreate(_PictureSourceCreateBase):
|
||||
stream_type: Literal["raw"] = "raw"
|
||||
display_index: int = Field(description="Display index", ge=0)
|
||||
capture_template_id: str = Field(description="Capture template ID")
|
||||
target_fps: int = Field(30, description="Target FPS", ge=1, le=90)
|
||||
|
||||
|
||||
class ProcessedPictureSourceCreate(_PictureSourceCreateBase):
|
||||
stream_type: Literal["processed"] = "processed"
|
||||
source_stream_id: str = Field(description="Source stream ID")
|
||||
postprocessing_template_id: str = Field(description="Postprocessing template ID")
|
||||
|
||||
|
||||
class StaticImagePictureSourceCreate(_PictureSourceCreateBase):
|
||||
stream_type: Literal["static_image"] = "static_image"
|
||||
image_asset_id: str = Field(description="Image asset ID")
|
||||
|
||||
|
||||
class VideoPictureSourceCreate(_PictureSourceCreateBase):
|
||||
stream_type: Literal["video"] = "video"
|
||||
video_asset_id: str = Field(description="Video asset ID")
|
||||
loop: bool = Field(True, description="Loop video playback")
|
||||
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
|
||||
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
|
||||
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
|
||||
resolution_limit: Optional[int] = Field(
|
||||
None, description="Max width in pixels for decode downscale", ge=64, le=7680
|
||||
)
|
||||
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
|
||||
target_fps: int = Field(30, description="Target FPS", ge=1, le=90)
|
||||
|
||||
|
||||
PictureSourceCreate = Annotated[
|
||||
Union[
|
||||
Annotated[RawPictureSourceCreate, Tag("raw")],
|
||||
Annotated[ProcessedPictureSourceCreate, Tag("processed")],
|
||||
Annotated[StaticImagePictureSourceCreate, Tag("static_image")],
|
||||
Annotated[VideoPictureSourceCreate, Tag("video")],
|
||||
],
|
||||
Discriminator("stream_type"),
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# Update schemas (per-type, discriminated union)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class _PictureSourceUpdateBase(BaseModel):
|
||||
"""Shared fields for all picture source update requests."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class RawPictureSourceUpdate(_PictureSourceUpdateBase):
|
||||
stream_type: Literal["raw"] = "raw"
|
||||
display_index: Optional[int] = Field(None, description="Display index", ge=0)
|
||||
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
|
||||
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
|
||||
|
||||
|
||||
class ProcessedPictureSourceUpdate(_PictureSourceUpdateBase):
|
||||
stream_type: Literal["processed"] = "processed"
|
||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
|
||||
postprocessing_template_id: Optional[str] = Field(
|
||||
None, description="Postprocessing template ID"
|
||||
)
|
||||
|
||||
|
||||
class StaticImagePictureSourceUpdate(_PictureSourceUpdateBase):
|
||||
stream_type: Literal["static_image"] = "static_image"
|
||||
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
|
||||
|
||||
|
||||
class VideoPictureSourceUpdate(_PictureSourceUpdateBase):
|
||||
stream_type: Literal["video"] = "video"
|
||||
video_asset_id: Optional[str] = Field(None, description="Video asset ID")
|
||||
loop: Optional[bool] = Field(None, description="Loop video playback")
|
||||
playback_speed: Optional[float] = Field(
|
||||
None, description="Playback speed multiplier", ge=0.1, le=10.0
|
||||
)
|
||||
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
|
||||
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
|
||||
resolution_limit: Optional[int] = Field(
|
||||
None, description="Max width in pixels for decode downscale", ge=64, le=7680
|
||||
)
|
||||
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
|
||||
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
|
||||
|
||||
|
||||
PictureSourceUpdate = Annotated[
|
||||
Union[
|
||||
Annotated[RawPictureSourceUpdate, Tag("raw")],
|
||||
Annotated[ProcessedPictureSourceUpdate, Tag("processed")],
|
||||
Annotated[StaticImagePictureSourceUpdate, Tag("static_image")],
|
||||
Annotated[VideoPictureSourceUpdate, Tag("video")],
|
||||
],
|
||||
Discriminator("stream_type"),
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# List response
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class PictureSourceListResponse(BaseModel):
|
||||
@@ -84,11 +187,23 @@ class PictureSourceListResponse(BaseModel):
|
||||
count: int = Field(description="Number of streams")
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Test / Validation (unchanged)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class PictureSourceTestRequest(BaseModel):
|
||||
"""Request to test a picture source."""
|
||||
|
||||
capture_duration: float = Field(default=5.0, ge=0.0, le=30.0, description="Duration to capture in seconds (0 = single frame)")
|
||||
border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels for preview")
|
||||
capture_duration: float = Field(
|
||||
default=5.0,
|
||||
ge=0.0,
|
||||
le=30.0,
|
||||
description="Duration to capture in seconds (0 = single frame)",
|
||||
)
|
||||
border_width: int = Field(
|
||||
default=10, ge=1, le=100, description="Border width in pixels for preview"
|
||||
)
|
||||
|
||||
|
||||
class ImageValidateRequest(BaseModel):
|
||||
|
||||
@@ -1,95 +1,402 @@
|
||||
"""Value source schemas (CRUD)."""
|
||||
"""Value source schemas — discriminated unions per source type."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Literal, Optional
|
||||
from typing import Annotated, List, Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Discriminator, Field, Tag
|
||||
|
||||
# =====================================================================
|
||||
# Response schemas (per-type, discriminated union)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class ValueSourceCreate(BaseModel):
|
||||
"""Request to create a value source."""
|
||||
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
source_type: Literal["static", "animated", "audio", "adaptive_time", "adaptive_scene", "daylight"] = Field(description="Source type")
|
||||
# static fields
|
||||
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
|
||||
# animated fields
|
||||
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
|
||||
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
|
||||
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
# audio fields
|
||||
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
|
||||
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
|
||||
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels to full range")
|
||||
# adaptive fields
|
||||
schedule: Optional[list] = Field(None, description="Time-of-day schedule: [{time: 'HH:MM', value: 0.0-1.0}]")
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
|
||||
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
|
||||
# daylight fields
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
|
||||
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class ValueSourceUpdate(BaseModel):
|
||||
"""Request to update a value source."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||
# static fields
|
||||
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
|
||||
# animated fields
|
||||
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
|
||||
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
|
||||
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
# audio fields
|
||||
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
|
||||
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
|
||||
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels to full range")
|
||||
# adaptive fields
|
||||
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
|
||||
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
|
||||
# daylight fields
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
|
||||
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ValueSourceResponse(BaseModel):
|
||||
"""Value source response."""
|
||||
class _ValueSourceResponseBase(BaseModel):
|
||||
"""Shared fields for all value source responses."""
|
||||
|
||||
id: str = Field(description="Source ID")
|
||||
name: str = Field(description="Source name")
|
||||
source_type: str = Field(description="Source type: static, animated, audio, adaptive_time, or adaptive_scene")
|
||||
value: Optional[float] = Field(None, description="Static value")
|
||||
waveform: Optional[str] = Field(None, description="Waveform type")
|
||||
speed: Optional[float] = Field(None, description="Cycles per minute")
|
||||
min_value: Optional[float] = Field(None, description="Minimum output")
|
||||
max_value: Optional[float] = Field(None, description="Maximum output")
|
||||
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
|
||||
mode: Optional[str] = Field(None, description="Audio mode")
|
||||
sensitivity: Optional[float] = Field(None, description="Gain multiplier")
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
|
||||
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels")
|
||||
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
|
||||
latitude: Optional[float] = Field(None, description="Geographic latitude")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
class StaticValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["static"] = "static"
|
||||
return_type: Literal["float"] = "float"
|
||||
value: float = Field(description="Constant value (0.0-1.0)")
|
||||
|
||||
|
||||
class AnimatedValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["animated"] = "animated"
|
||||
return_type: Literal["float"] = "float"
|
||||
waveform: str = Field(description="Waveform type")
|
||||
speed: float = Field(description="Cycles per minute")
|
||||
min_value: float = Field(description="Minimum output")
|
||||
max_value: float = Field(description="Maximum output")
|
||||
|
||||
|
||||
class AudioValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["audio"] = "audio"
|
||||
return_type: Literal["float"] = "float"
|
||||
audio_source_id: str = Field(description="Mono audio source ID")
|
||||
mode: str = Field(description="Audio mode: rms|peak|beat")
|
||||
sensitivity: float = Field(description="Gain multiplier")
|
||||
smoothing: float = Field(description="Temporal smoothing")
|
||||
min_value: float = Field(description="Minimum output")
|
||||
max_value: float = Field(description="Maximum output")
|
||||
auto_gain: bool = Field(description="Auto-normalize audio levels")
|
||||
|
||||
|
||||
class AdaptiveTimeValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["adaptive_time"] = "adaptive_time"
|
||||
return_type: Literal["float"] = "float"
|
||||
schedule: list = Field(description="Time-of-day schedule")
|
||||
min_value: float = Field(description="Minimum output")
|
||||
max_value: float = Field(description="Maximum output")
|
||||
|
||||
|
||||
class AdaptiveSceneValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["adaptive_scene"] = "adaptive_scene"
|
||||
return_type: Literal["float"] = "float"
|
||||
picture_source_id: str = Field(description="Picture source ID")
|
||||
scene_behavior: str = Field(description="Scene behavior: complement|match")
|
||||
sensitivity: float = Field(description="Gain multiplier")
|
||||
smoothing: float = Field(description="Temporal smoothing")
|
||||
min_value: float = Field(description="Minimum output")
|
||||
max_value: float = Field(description="Maximum output")
|
||||
|
||||
|
||||
class DaylightValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["daylight"] = "daylight"
|
||||
return_type: Literal["float"] = "float"
|
||||
speed: float = Field(description="Simulation speed multiplier")
|
||||
use_real_time: bool = Field(description="Use wall-clock time")
|
||||
latitude: float = Field(description="Geographic latitude")
|
||||
min_value: float = Field(description="Minimum output")
|
||||
max_value: float = Field(description="Maximum output")
|
||||
|
||||
|
||||
class StaticColorValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["static_color"] = "static_color"
|
||||
return_type: Literal["color"] = "color"
|
||||
color: List[int] = Field(description="Static RGB color [R,G,B]")
|
||||
|
||||
|
||||
class AnimatedColorValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["animated_color"] = "animated_color"
|
||||
return_type: Literal["color"] = "color"
|
||||
colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]")
|
||||
speed: float = Field(description="Cycles per minute")
|
||||
easing: str = Field(description="Color easing: linear|step")
|
||||
|
||||
|
||||
class AdaptiveTimeColorValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
|
||||
return_type: Literal["color"] = "color"
|
||||
schedule: list = Field(description="Color schedule")
|
||||
|
||||
|
||||
class HAEntityValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["ha_entity"] = "ha_entity"
|
||||
return_type: Literal["float"] = "float"
|
||||
ha_source_id: str = Field(description="Home Assistant source ID")
|
||||
entity_id: str = Field(description="HA entity ID (e.g. sensor.temperature)")
|
||||
attribute: str = Field("", description="Optional attribute name (empty = use state)")
|
||||
min_ha_value: float = Field(description="Raw HA value mapped to output 0.0")
|
||||
max_ha_value: float = Field(description="Raw HA value mapped to output 1.0")
|
||||
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
|
||||
|
||||
|
||||
class GradientMapValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["gradient_map"] = "gradient_map"
|
||||
return_type: Literal["color"] = "color"
|
||||
value_source_id: str = Field(description="Input float value source ID")
|
||||
gradient_id: str = Field(description="Gradient entity ID")
|
||||
easing: str = Field(description="Interpolation mode: linear|step")
|
||||
|
||||
|
||||
class CSSExtractValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["css_extract"] = "css_extract"
|
||||
return_type: Literal["color"] = "color"
|
||||
color_strip_source_id: str = Field(description="Color strip source ID")
|
||||
led_start: int = Field(description="Start of LED range (0-based)")
|
||||
led_end: int = Field(description="End of LED range (-1 = whole strip)")
|
||||
|
||||
|
||||
ValueSourceResponse = Annotated[
|
||||
Union[
|
||||
Annotated[StaticValueSourceResponse, Tag("static")],
|
||||
Annotated[AnimatedValueSourceResponse, Tag("animated")],
|
||||
Annotated[AudioValueSourceResponse, Tag("audio")],
|
||||
Annotated[AdaptiveTimeValueSourceResponse, Tag("adaptive_time")],
|
||||
Annotated[AdaptiveSceneValueSourceResponse, Tag("adaptive_scene")],
|
||||
Annotated[DaylightValueSourceResponse, Tag("daylight")],
|
||||
Annotated[StaticColorValueSourceResponse, Tag("static_color")],
|
||||
Annotated[AnimatedColorValueSourceResponse, Tag("animated_color")],
|
||||
Annotated[AdaptiveTimeColorValueSourceResponse, Tag("adaptive_time_color")],
|
||||
Annotated[HAEntityValueSourceResponse, Tag("ha_entity")],
|
||||
Annotated[GradientMapValueSourceResponse, Tag("gradient_map")],
|
||||
Annotated[CSSExtractValueSourceResponse, Tag("css_extract")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# Create schemas (per-type, discriminated union)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class _ValueSourceCreateBase(BaseModel):
|
||||
"""Shared fields for all value source create requests."""
|
||||
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class StaticValueSourceCreate(_ValueSourceCreateBase):
|
||||
source_type: Literal["static"] = "static"
|
||||
value: float = Field(1.0, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class AnimatedValueSourceCreate(_ValueSourceCreateBase):
|
||||
source_type: Literal["animated"] = "animated"
|
||||
waveform: str = Field("sine", description="Waveform: sine|triangle|square|sawtooth")
|
||||
speed: float = Field(10.0, description="Cycles per minute", ge=0.1, le=120.0)
|
||||
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class AudioValueSourceCreate(_ValueSourceCreateBase):
|
||||
source_type: Literal["audio"] = "audio"
|
||||
audio_source_id: str = Field("", description="Mono audio source ID")
|
||||
mode: str = Field("rms", description="Audio mode: rms|peak|beat")
|
||||
sensitivity: float = Field(1.0, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
|
||||
smoothing: float = Field(0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
auto_gain: bool = Field(False, description="Auto-normalize audio levels to full range")
|
||||
|
||||
|
||||
class AdaptiveTimeValueSourceCreate(_ValueSourceCreateBase):
|
||||
source_type: Literal["adaptive_time"] = "adaptive_time"
|
||||
schedule: list = Field(description="Schedule: [{time: 'HH:MM', value: 0.0-1.0}]")
|
||||
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class AdaptiveSceneValueSourceCreate(_ValueSourceCreateBase):
|
||||
source_type: Literal["adaptive_scene"] = "adaptive_scene"
|
||||
picture_source_id: str = Field("", description="Picture source ID for scene mode")
|
||||
scene_behavior: str = Field("complement", description="Scene behavior: complement|match")
|
||||
sensitivity: float = Field(1.0, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
|
||||
smoothing: float = Field(0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class DaylightValueSourceCreate(_ValueSourceCreateBase):
|
||||
source_type: Literal["daylight"] = "daylight"
|
||||
speed: float = Field(1.0, description="Simulation speed multiplier", ge=0.1, le=120.0)
|
||||
use_real_time: bool = Field(False, description="Use wall-clock time instead of simulation")
|
||||
latitude: float = Field(50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
|
||||
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class StaticColorValueSourceCreate(_ValueSourceCreateBase):
|
||||
source_type: Literal["static_color"] = "static_color"
|
||||
color: List[int] = Field(
|
||||
default_factory=lambda: [255, 255, 255],
|
||||
description="Static RGB color [R,G,B]",
|
||||
)
|
||||
|
||||
|
||||
class AnimatedColorValueSourceCreate(_ValueSourceCreateBase):
|
||||
source_type: Literal["animated_color"] = "animated_color"
|
||||
colors: List[List[int]] = Field(
|
||||
default_factory=lambda: [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
|
||||
description="Color list [[R,G,B], ...]",
|
||||
)
|
||||
speed: float = Field(10.0, description="Cycles per minute", ge=0.1, le=120.0)
|
||||
easing: str = Field("linear", description="Color easing: linear|step")
|
||||
|
||||
|
||||
class AdaptiveTimeColorValueSourceCreate(_ValueSourceCreateBase):
|
||||
source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
|
||||
schedule: list = Field(description="Schedule: [{time: 'HH:MM', color: [R,G,B]}]")
|
||||
|
||||
|
||||
class HAEntityValueSourceCreate(_ValueSourceCreateBase):
|
||||
source_type: Literal["ha_entity"] = "ha_entity"
|
||||
ha_source_id: str = Field(description="Home Assistant source ID")
|
||||
entity_id: str = Field(description="HA entity ID")
|
||||
attribute: str = Field("", description="Optional attribute name")
|
||||
min_ha_value: float = Field(0.0, description="Raw HA value mapped to output 0.0")
|
||||
max_ha_value: float = Field(100.0, description="Raw HA value mapped to output 1.0")
|
||||
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class GradientMapValueSourceCreate(_ValueSourceCreateBase):
|
||||
source_type: Literal["gradient_map"] = "gradient_map"
|
||||
value_source_id: str = Field(description="Input float value source ID")
|
||||
gradient_id: str = Field("", description="Gradient entity ID")
|
||||
easing: str = Field("linear", description="Interpolation: linear|step")
|
||||
|
||||
|
||||
class CSSExtractValueSourceCreate(_ValueSourceCreateBase):
|
||||
source_type: Literal["css_extract"] = "css_extract"
|
||||
color_strip_source_id: str = Field(description="Color strip source ID")
|
||||
led_start: int = Field(0, description="Start of LED range (0-based)", ge=0)
|
||||
led_end: int = Field(-1, description="End of LED range (-1 = whole strip)")
|
||||
|
||||
|
||||
ValueSourceCreate = Annotated[
|
||||
Union[
|
||||
Annotated[StaticValueSourceCreate, Tag("static")],
|
||||
Annotated[AnimatedValueSourceCreate, Tag("animated")],
|
||||
Annotated[AudioValueSourceCreate, Tag("audio")],
|
||||
Annotated[AdaptiveTimeValueSourceCreate, Tag("adaptive_time")],
|
||||
Annotated[AdaptiveSceneValueSourceCreate, Tag("adaptive_scene")],
|
||||
Annotated[DaylightValueSourceCreate, Tag("daylight")],
|
||||
Annotated[StaticColorValueSourceCreate, Tag("static_color")],
|
||||
Annotated[AnimatedColorValueSourceCreate, Tag("animated_color")],
|
||||
Annotated[AdaptiveTimeColorValueSourceCreate, Tag("adaptive_time_color")],
|
||||
Annotated[HAEntityValueSourceCreate, Tag("ha_entity")],
|
||||
Annotated[GradientMapValueSourceCreate, Tag("gradient_map")],
|
||||
Annotated[CSSExtractValueSourceCreate, Tag("css_extract")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# Update schemas (per-type, discriminated union)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class _ValueSourceUpdateBase(BaseModel):
|
||||
"""Shared fields for all value source update requests."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class StaticValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["static"] = "static"
|
||||
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class AnimatedValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["animated"] = "animated"
|
||||
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
|
||||
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
|
||||
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
|
||||
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class AudioValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["audio"] = "audio"
|
||||
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
|
||||
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
|
||||
sensitivity: Optional[float] = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
|
||||
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
|
||||
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
|
||||
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels")
|
||||
|
||||
|
||||
class AdaptiveTimeValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["adaptive_time"] = "adaptive_time"
|
||||
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
|
||||
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
|
||||
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class AdaptiveSceneValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["adaptive_scene"] = "adaptive_scene"
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
|
||||
sensitivity: Optional[float] = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
|
||||
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
|
||||
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class DaylightValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["daylight"] = "daylight"
|
||||
speed: Optional[float] = Field(None, description="Simulation speed", ge=0.1, le=120.0)
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
|
||||
latitude: Optional[float] = Field(None, description="Geographic latitude", ge=-90.0, le=90.0)
|
||||
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
|
||||
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class StaticColorValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["static_color"] = "static_color"
|
||||
color: Optional[List[int]] = Field(None, description="Static RGB color [R,G,B]")
|
||||
|
||||
|
||||
class AnimatedColorValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["animated_color"] = "animated_color"
|
||||
colors: Optional[List[List[int]]] = Field(None, description="Color list [[R,G,B], ...]")
|
||||
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
|
||||
easing: Optional[str] = Field(None, description="Color easing: linear|step")
|
||||
|
||||
|
||||
class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
|
||||
schedule: Optional[list] = Field(None, description="Color schedule")
|
||||
|
||||
|
||||
class HAEntityValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["ha_entity"] = "ha_entity"
|
||||
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
|
||||
entity_id: Optional[str] = Field(None, description="HA entity ID")
|
||||
attribute: Optional[str] = Field(None, description="Attribute name")
|
||||
min_ha_value: Optional[float] = Field(None, description="Min HA value")
|
||||
max_ha_value: Optional[float] = Field(None, description="Max HA value")
|
||||
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class GradientMapValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["gradient_map"] = "gradient_map"
|
||||
value_source_id: Optional[str] = Field(None, description="Input value source ID")
|
||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
|
||||
easing: Optional[str] = Field(None, description="Interpolation mode")
|
||||
|
||||
|
||||
class CSSExtractValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["css_extract"] = "css_extract"
|
||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||
led_start: Optional[int] = Field(None, description="LED range start", ge=0)
|
||||
led_end: Optional[int] = Field(None, description="LED range end")
|
||||
|
||||
|
||||
ValueSourceUpdate = Annotated[
|
||||
Union[
|
||||
Annotated[StaticValueSourceUpdate, Tag("static")],
|
||||
Annotated[AnimatedValueSourceUpdate, Tag("animated")],
|
||||
Annotated[AudioValueSourceUpdate, Tag("audio")],
|
||||
Annotated[AdaptiveTimeValueSourceUpdate, Tag("adaptive_time")],
|
||||
Annotated[AdaptiveSceneValueSourceUpdate, Tag("adaptive_scene")],
|
||||
Annotated[DaylightValueSourceUpdate, Tag("daylight")],
|
||||
Annotated[StaticColorValueSourceUpdate, Tag("static_color")],
|
||||
Annotated[AnimatedColorValueSourceUpdate, Tag("animated_color")],
|
||||
Annotated[AdaptiveTimeColorValueSourceUpdate, Tag("adaptive_time_color")],
|
||||
Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")],
|
||||
Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")],
|
||||
Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# List response
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class ValueSourceListResponse(BaseModel):
|
||||
"""List of value sources."""
|
||||
|
||||
|
||||
@@ -43,10 +43,9 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
self._fps = 30
|
||||
|
||||
# Parse config
|
||||
fallback = source.fallback_color
|
||||
self._fallback_color = (
|
||||
fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
|
||||
)
|
||||
from wled_controller.storage.bindable import bcolor
|
||||
|
||||
self._fallback_color = bcolor(source.fallback_color, [0, 0, 0])
|
||||
from wled_controller.storage.bindable import bfloat
|
||||
|
||||
self._timeout = max(0.0, bfloat(source.timeout, 5.0))
|
||||
|
||||
@@ -93,12 +93,10 @@ class AudioColorStripStream(ColorStripStream):
|
||||
self._gradient_id = getattr(source, "gradient_id", None)
|
||||
self._palette_name = getattr(source, "palette", "rainbow")
|
||||
self._resolve_palette_lut()
|
||||
color = getattr(source, "color", None)
|
||||
self._color = color if isinstance(color, list) and len(color) == 3 else [0, 255, 0]
|
||||
color_peak = getattr(source, "color_peak", None)
|
||||
self._color_peak = (
|
||||
color_peak if isinstance(color_peak, list) and len(color_peak) == 3 else [255, 0, 0]
|
||||
)
|
||||
from wled_controller.storage.bindable import bcolor
|
||||
|
||||
self._color = bcolor(getattr(source, "color", None), [0, 255, 0])
|
||||
self._color_peak = bcolor(getattr(source, "color_peak", None), [255, 0, 0])
|
||||
# Pre-computed float arrays for VU meter (avoid per-frame np.array())
|
||||
self._color_f = np.array(self._color, dtype=np.float32)
|
||||
self._color_peak_f = np.array(self._color_peak, dtype=np.float32)
|
||||
@@ -426,8 +424,8 @@ class AudioColorStripStream(ColorStripStream):
|
||||
buf[:] = 0
|
||||
|
||||
if fill_count > 0:
|
||||
base = self._color_f
|
||||
peak = self._color_peak_f
|
||||
base = np.array(self.resolve_color("color", self._color), dtype=np.float32)
|
||||
peak = np.array(self.resolve_color("color_peak", self._color_peak), dtype=np.float32)
|
||||
t = self._vu_gradient[:fill_count]
|
||||
for ch in range(3):
|
||||
buf[:fill_count, ch] = np.clip(base[ch] + (peak[ch] - base[ch]) * t, 0, 255).astype(
|
||||
|
||||
@@ -85,10 +85,9 @@ class CandlelightColorStripStream(ColorStripStream):
|
||||
self._update_from_source(source)
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
raw_color = getattr(source, "color", None)
|
||||
self._color = (
|
||||
raw_color if isinstance(raw_color, list) and len(raw_color) == 3 else [255, 147, 41]
|
||||
)
|
||||
from wled_controller.storage.bindable import bcolor
|
||||
|
||||
self._color = bcolor(getattr(source, "color", None), [255, 147, 41])
|
||||
from wled_controller.storage.bindable import bfloat
|
||||
|
||||
self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0)
|
||||
@@ -261,7 +260,8 @@ class CandlelightColorStripStream(ColorStripStream):
|
||||
eff_speed = speed * 0.35 * spd_mul
|
||||
intensity = self.resolve("intensity", self._intensity)
|
||||
num_candles = self._num_candles
|
||||
base_r, base_g, base_b = self._color[0], self._color[1], self._color[2]
|
||||
_c = self.resolve_color("color", self._color)
|
||||
base_r, base_g, base_b = _c[0], _c[1], _c[2]
|
||||
|
||||
# Wind modulation
|
||||
wind_strength = self.resolve("wind_strength", self._wind_strength)
|
||||
|
||||
@@ -25,7 +25,7 @@ from wled_controller.core.capture.calibration import (
|
||||
create_pixel_mapper,
|
||||
)
|
||||
from wled_controller.core.capture.screen_capture import extract_border_pixels
|
||||
from wled_controller.storage.bindable import bfloat
|
||||
from wled_controller.storage.bindable import bcolor, bfloat
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.utils.timer import high_resolution_timer
|
||||
|
||||
@@ -136,6 +136,18 @@ class ColorStripStream(ABC):
|
||||
pass
|
||||
return static
|
||||
|
||||
def resolve_color(self, prop: str, static: list) -> list:
|
||||
"""Resolve a bindable color: ValueStream color if bound, else static [R,G,B]."""
|
||||
if self._value_streams:
|
||||
vs = self._value_streams.get(prop)
|
||||
if vs is not None:
|
||||
try:
|
||||
c = vs.get_color()
|
||||
return [c[0], c[1], c[2]]
|
||||
except Exception:
|
||||
pass
|
||||
return static
|
||||
|
||||
|
||||
class PictureColorStripStream(ColorStripStream):
|
||||
"""Color strip stream backed by a LiveStream (picture source).
|
||||
@@ -507,12 +519,7 @@ class StaticColorStripStream(ColorStripStream):
|
||||
self._update_from_source(source)
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
color = (
|
||||
source.color
|
||||
if isinstance(source.color, list) and len(source.color) == 3
|
||||
else [255, 255, 255]
|
||||
)
|
||||
self._source_color = color # stored separately so configure() can rebuild
|
||||
self._source_color = bcolor(source.color, [255, 255, 255])
|
||||
_lc = getattr(source, "led_count", 0)
|
||||
self._auto_size = not _lc
|
||||
led_count = _lc if _lc and _lc > 0 else 1
|
||||
@@ -643,7 +650,7 @@ class StaticColorStripStream(ColorStripStream):
|
||||
|
||||
if atype == "breathing":
|
||||
factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5))
|
||||
r, g, b = self._source_color
|
||||
r, g, b = self.resolve_color("color", self._source_color)
|
||||
buf[:] = (
|
||||
min(255, int(r * factor)),
|
||||
min(255, int(g * factor)),
|
||||
@@ -655,14 +662,14 @@ class StaticColorStripStream(ColorStripStream):
|
||||
# Square wave: on for half the period, off for the other half.
|
||||
# speed=1.0 → 2 flashes/sec (one full on/off cycle per 0.5s)
|
||||
if math.sin(2 * math.pi * speed * t * 2.0) >= 0:
|
||||
buf[:] = self._source_color
|
||||
buf[:] = self.resolve_color("color", self._source_color)
|
||||
else:
|
||||
buf[:] = 0
|
||||
colors = buf
|
||||
|
||||
elif atype == "sparkle":
|
||||
# Random LEDs flash white while the rest stay the base color
|
||||
buf[:] = self._source_color
|
||||
buf[:] = self.resolve_color("color", self._source_color)
|
||||
density = min(0.5, 0.1 * speed)
|
||||
mask = np.random.random(n) < density
|
||||
buf[mask] = (255, 255, 255)
|
||||
@@ -676,7 +683,7 @@ class StaticColorStripStream(ColorStripStream):
|
||||
factor = phase / 0.1
|
||||
else:
|
||||
factor = math.exp(-5.0 * (phase - 0.1))
|
||||
r, g, b = self._source_color
|
||||
r, g, b = self.resolve_color("color", self._source_color)
|
||||
buf[:] = (
|
||||
min(255, int(r * factor)),
|
||||
min(255, int(g * factor)),
|
||||
@@ -691,7 +698,7 @@ class StaticColorStripStream(ColorStripStream):
|
||||
flicker += 0.15 * math.sin(2 * math.pi * speed * t * 7.3)
|
||||
flicker += 0.10 * (np.random.random() - 0.5)
|
||||
factor = max(0.2, min(1.0, base_factor + flicker))
|
||||
r, g, b = self._source_color
|
||||
r, g, b = self.resolve_color("color", self._source_color)
|
||||
buf[:] = (
|
||||
min(255, int(r * factor)),
|
||||
min(255, int(g * factor)),
|
||||
@@ -701,7 +708,7 @@ class StaticColorStripStream(ColorStripStream):
|
||||
|
||||
elif atype == "rainbow_fade":
|
||||
# Shift hue continuously from the base color
|
||||
r, g, b = self._source_color
|
||||
r, g, b = self.resolve_color("color", self._source_color)
|
||||
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
|
||||
# speed=1.0 → one full hue rotation every ~10s
|
||||
h_shift = (speed * t * 0.1) % 1.0
|
||||
|
||||
@@ -147,7 +147,7 @@ class ColorStripStreamManager:
|
||||
logger.debug("Sync clock release during stream cleanup: %s", e)
|
||||
pass # source may have been deleted already
|
||||
|
||||
# Properties that can be BindableFloat on any CSS source
|
||||
# Properties that can be BindableFloat or BindableColor on any CSS source
|
||||
_BINDABLE_PROPS = (
|
||||
"smoothing",
|
||||
"sensitivity",
|
||||
@@ -160,19 +160,23 @@ class ColorStripStreamManager:
|
||||
"timeout",
|
||||
"brightness",
|
||||
"duration_ms",
|
||||
"color",
|
||||
"color_peak",
|
||||
"fallback_color",
|
||||
"default_color",
|
||||
)
|
||||
|
||||
def _bind_value_streams(
|
||||
self, css_stream: ColorStripStream, source, entry: _ColorStripEntry
|
||||
) -> None:
|
||||
"""Acquire ValueStreams for any bound BindableFloat properties and inject into stream."""
|
||||
"""Acquire ValueStreams for any bound BindableFloat/BindableColor properties."""
|
||||
if not self._value_stream_manager:
|
||||
return
|
||||
from wled_controller.storage.bindable import BindableFloat
|
||||
from wled_controller.storage.bindable import BindableColor, BindableFloat
|
||||
|
||||
for prop in self._BINDABLE_PROPS:
|
||||
bf = getattr(source, prop, None)
|
||||
if isinstance(bf, BindableFloat) and bf.source_id:
|
||||
if isinstance(bf, (BindableFloat, BindableColor)) and bf.source_id:
|
||||
try:
|
||||
vs = self._value_stream_manager.acquire(bf.source_id)
|
||||
css_stream.set_value_stream(prop, vs)
|
||||
|
||||
@@ -286,8 +286,9 @@ class EffectColorStripStream(ColorStripStream):
|
||||
)
|
||||
self._custom_palette = getattr(source, "custom_palette", None)
|
||||
self._resolve_palette_lut()
|
||||
color = getattr(source, "color", None)
|
||||
self._color = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0]
|
||||
from wled_controller.storage.bindable import bcolor
|
||||
|
||||
self._color = bcolor(getattr(source, "color", None), [255, 80, 0])
|
||||
from wled_controller.storage.bindable import bfloat
|
||||
|
||||
self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0)
|
||||
@@ -490,7 +491,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
"""Bright meteor head with exponential-decay trail."""
|
||||
speed = self._effective_speed
|
||||
intensity = self.resolve("intensity", self._intensity)
|
||||
color = self._color
|
||||
color = self.resolve_color("color", self._color)
|
||||
mirror = self._mirror
|
||||
|
||||
# Compute position along the strip
|
||||
@@ -686,7 +687,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
"""Multiple comets with curved, pulsing tails."""
|
||||
speed = self._effective_speed
|
||||
intensity = self.resolve("intensity", self._intensity)
|
||||
color = self._color
|
||||
color = self.resolve_color("color", self._color)
|
||||
mirror = self._mirror
|
||||
|
||||
indices = self._s_arange
|
||||
@@ -732,7 +733,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
"""Physics-simulated bouncing balls with gravity."""
|
||||
speed = self._effective_speed
|
||||
intensity = self.resolve("intensity", self._intensity)
|
||||
color = self._color
|
||||
color = self.resolve_color("color", self._color)
|
||||
|
||||
num_balls = 3
|
||||
# Initialize ball state on first call or LED count change
|
||||
|
||||
@@ -76,7 +76,9 @@ class NotificationColorStripStream(ColorStripStream):
|
||||
"""Parse config from source dataclass."""
|
||||
self._notification_effect = getattr(source, "notification_effect", "flash")
|
||||
self._duration_ms = max(100, int(bfloat(getattr(source, "duration_ms", 1500), 1500)))
|
||||
self._default_color = getattr(source, "default_color", "#FFFFFF")
|
||||
from wled_controller.storage.bindable import bcolor
|
||||
|
||||
self._default_color = bcolor(getattr(source, "default_color", None), [255, 255, 255])
|
||||
self._app_colors = {
|
||||
k.lower(): v for k, v in dict(getattr(source, "app_colors", {})).items()
|
||||
}
|
||||
@@ -121,7 +123,7 @@ class NotificationColorStripStream(ColorStripStream):
|
||||
elif app_lower and app_lower in self._app_colors:
|
||||
color = _hex_to_rgb(self._app_colors[app_lower])
|
||||
else:
|
||||
color = _hex_to_rgb(self._default_color)
|
||||
color = self.resolve_color("default_color", self._default_color)
|
||||
|
||||
# Push event to queue (thread-safe deque.append)
|
||||
# Priority: 0 = normal, 1 = high (high interrupts current effect)
|
||||
|
||||
@@ -159,6 +159,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
audio_source_store=deps.audio_source_store,
|
||||
live_stream_manager=self._live_stream_manager,
|
||||
audio_template_store=deps.audio_template_store,
|
||||
ha_manager=deps.ha_manager,
|
||||
css_stream_manager=self._color_strip_stream_manager,
|
||||
gradient_store=deps.gradient_store,
|
||||
)
|
||||
if deps.value_source_store
|
||||
else None
|
||||
|
||||
@@ -24,7 +24,7 @@ import math
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -32,6 +32,8 @@ from wled_controller.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wled_controller.core.audio.audio_capture import AudioCaptureManager
|
||||
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
|
||||
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
|
||||
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
from wled_controller.storage.value_source import ValueSource
|
||||
@@ -44,6 +46,7 @@ logger = get_logger(__name__)
|
||||
# Base class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ValueStream(ABC):
|
||||
"""Abstract base for runtime value streams."""
|
||||
|
||||
@@ -52,6 +55,10 @@ class ValueStream(ABC):
|
||||
"""Return current scalar value (0.0–1.0)."""
|
||||
...
|
||||
|
||||
def get_color(self) -> tuple:
|
||||
"""Return current RGB color as (R, G, B). Override in color streams."""
|
||||
raise NotImplementedError("This stream does not produce colors")
|
||||
|
||||
def start(self) -> None:
|
||||
"""Acquire resources (if any)."""
|
||||
|
||||
@@ -66,6 +73,7 @@ class ValueStream(ABC):
|
||||
# Static
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StaticValueStream(ValueStream):
|
||||
"""Returns a constant float."""
|
||||
|
||||
@@ -77,6 +85,7 @@ class StaticValueStream(ValueStream):
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
from wled_controller.storage.value_source import StaticValueSource
|
||||
|
||||
if isinstance(source, StaticValueSource):
|
||||
self._value = max(0.0, min(1.0, source.value))
|
||||
|
||||
@@ -133,6 +142,7 @@ class AnimatedValueStream(ValueStream):
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
from wled_controller.storage.value_source import AnimatedValueSource
|
||||
|
||||
if isinstance(source, AnimatedValueSource):
|
||||
self._waveform = source.waveform
|
||||
self._speed = source.speed
|
||||
@@ -144,6 +154,7 @@ class AnimatedValueStream(ValueStream):
|
||||
# Audio
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AudioValueStream(ValueStream):
|
||||
"""Polls audio analysis for a scalar value.
|
||||
|
||||
@@ -173,8 +184,8 @@ class AudioValueStream(ValueStream):
|
||||
self._min = min_value
|
||||
self._max = max_value
|
||||
self._auto_gain = auto_gain
|
||||
self._rolling_peak = 0.0 # tracks observed max raw audio value
|
||||
self._rolling_decay = 0.995 # slow decay (~5-10s adaptation)
|
||||
self._rolling_peak = 0.0 # tracks observed max raw audio value
|
||||
self._rolling_decay = 0.995 # slow decay (~5-10s adaptation)
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
self._audio_source_store = audio_source_store
|
||||
self._audio_template_store = audio_template_store
|
||||
@@ -208,7 +219,11 @@ class AudioValueStream(ValueStream):
|
||||
self._audio_engine_type = tpl.engine_type
|
||||
self._audio_engine_config = tpl.engine_config
|
||||
except ValueError as e:
|
||||
logger.warning("Audio template %s not found for value stream, using default engine: %s", template_id, e)
|
||||
logger.warning(
|
||||
"Audio template %s not found for value stream, using default engine: %s",
|
||||
template_id,
|
||||
e,
|
||||
)
|
||||
pass
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to resolve audio source {self._audio_source_id}: {e}")
|
||||
@@ -217,7 +232,8 @@ class AudioValueStream(ValueStream):
|
||||
if self._audio_capture_manager is None:
|
||||
return
|
||||
self._audio_stream = self._audio_capture_manager.acquire(
|
||||
self._audio_device_index, self._audio_loopback,
|
||||
self._audio_device_index,
|
||||
self._audio_loopback,
|
||||
engine_type=self._audio_engine_type,
|
||||
engine_config=self._audio_engine_config,
|
||||
)
|
||||
@@ -229,7 +245,8 @@ class AudioValueStream(ValueStream):
|
||||
def stop(self) -> None:
|
||||
if self._audio_stream is not None and self._audio_capture_manager is not None:
|
||||
self._audio_capture_manager.release(
|
||||
self._audio_device_index, self._audio_loopback,
|
||||
self._audio_device_index,
|
||||
self._audio_loopback,
|
||||
engine_type=self._audio_engine_type,
|
||||
)
|
||||
self._audio_stream = None
|
||||
@@ -295,6 +312,7 @@ class AudioValueStream(ValueStream):
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
from wled_controller.storage.value_source import AudioValueSource
|
||||
|
||||
if not isinstance(source, AudioValueSource):
|
||||
return
|
||||
|
||||
@@ -320,10 +338,13 @@ class AudioValueStream(ValueStream):
|
||||
self._resolve_audio_source()
|
||||
if self._audio_stream is not None and self._audio_capture_manager is not None:
|
||||
self._audio_capture_manager.release(
|
||||
old_device, old_loopback, engine_type=old_engine_type,
|
||||
old_device,
|
||||
old_loopback,
|
||||
engine_type=old_engine_type,
|
||||
)
|
||||
self._audio_stream = self._audio_capture_manager.acquire(
|
||||
self._audio_device_index, self._audio_loopback,
|
||||
self._audio_device_index,
|
||||
self._audio_loopback,
|
||||
engine_type=self._audio_engine_type,
|
||||
engine_config=self._audio_engine_config,
|
||||
)
|
||||
@@ -403,7 +424,9 @@ class TimeOfDayValueStream(ValueStream):
|
||||
elapsed = current - t_left
|
||||
else:
|
||||
interval = (_MINUTES_PER_DAY - t_left) + t_right
|
||||
elapsed = (current - t_left) if current >= t_left else (_MINUTES_PER_DAY - t_left) + current
|
||||
elapsed = (
|
||||
(current - t_left) if current >= t_left else (_MINUTES_PER_DAY - t_left) + current
|
||||
)
|
||||
|
||||
frac = elapsed / interval if interval > 0 else 0.0
|
||||
raw = v_left + frac * (v_right - v_left)
|
||||
@@ -413,6 +436,7 @@ class TimeOfDayValueStream(ValueStream):
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
from wled_controller.storage.value_source import AdaptiveValueSource
|
||||
|
||||
if isinstance(source, AdaptiveValueSource) and source.source_type == "adaptive_time":
|
||||
self._parse_schedule(source.schedule)
|
||||
self._min = source.min_value
|
||||
@@ -423,6 +447,7 @@ class TimeOfDayValueStream(ValueStream):
|
||||
# Scene
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SceneValueStream(ValueStream):
|
||||
"""Derives brightness from a picture source's average frame luminance.
|
||||
|
||||
@@ -458,12 +483,8 @@ class SceneValueStream(ValueStream):
|
||||
def start(self) -> None:
|
||||
if self._live_stream_manager and self._picture_source_id:
|
||||
try:
|
||||
self._live_stream = self._live_stream_manager.acquire(
|
||||
self._picture_source_id
|
||||
)
|
||||
logger.info(
|
||||
f"SceneValueStream acquired live stream for {self._picture_source_id}"
|
||||
)
|
||||
self._live_stream = self._live_stream_manager.acquire(self._picture_source_id)
|
||||
logger.info(f"SceneValueStream acquired live stream for {self._picture_source_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"SceneValueStream failed to acquire live stream: {e}")
|
||||
self._live_stream = None
|
||||
@@ -493,9 +514,14 @@ class SceneValueStream(ValueStream):
|
||||
sampled = img[::step_h, ::step_w].astype(np.float32)
|
||||
|
||||
# BT.601 weighted luminance, normalized to [0, 1]
|
||||
luminance = float(
|
||||
(0.299 * sampled[:, :, 0] + 0.587 * sampled[:, :, 1] + 0.114 * sampled[:, :, 2]).mean()
|
||||
) / 255.0
|
||||
luminance = (
|
||||
float(
|
||||
(
|
||||
0.299 * sampled[:, :, 0] + 0.587 * sampled[:, :, 1] + 0.114 * sampled[:, :, 2]
|
||||
).mean()
|
||||
)
|
||||
/ 255.0
|
||||
)
|
||||
|
||||
# Apply sensitivity
|
||||
raw = min(1.0, luminance * self._sensitivity)
|
||||
@@ -517,6 +543,7 @@ class SceneValueStream(ValueStream):
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
from wled_controller.storage.value_source import AdaptiveValueSource
|
||||
|
||||
if not isinstance(source, AdaptiveValueSource) or source.source_type != "adaptive_scene":
|
||||
return
|
||||
|
||||
@@ -533,9 +560,7 @@ class SceneValueStream(ValueStream):
|
||||
if self._live_stream is not None and self._live_stream_manager:
|
||||
self._live_stream_manager.release(old_id)
|
||||
try:
|
||||
self._live_stream = self._live_stream_manager.acquire(
|
||||
self._picture_source_id
|
||||
)
|
||||
self._live_stream = self._live_stream_manager.acquire(self._picture_source_id)
|
||||
logger.info(
|
||||
f"SceneValueStream swapped live stream: {old_id} → "
|
||||
f"{self._picture_source_id}"
|
||||
@@ -567,6 +592,7 @@ class DaylightValueStream(ValueStream):
|
||||
max_value: float = 1.0,
|
||||
):
|
||||
from wled_controller.core.processing.daylight_stream import _get_daylight_lut
|
||||
|
||||
self._lut = _get_daylight_lut()
|
||||
self._speed = speed
|
||||
self._use_real_time = use_real_time
|
||||
@@ -595,6 +621,7 @@ class DaylightValueStream(ValueStream):
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
from wled_controller.storage.value_source import DaylightValueSource
|
||||
|
||||
if isinstance(source, DaylightValueSource):
|
||||
self._speed = source.speed
|
||||
self._use_real_time = source.use_real_time
|
||||
@@ -603,10 +630,500 @@ class DaylightValueStream(ValueStream):
|
||||
self._max = source.max_value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Color streams
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StaticColorValueStream(ValueStream):
|
||||
"""Returns a constant RGB color."""
|
||||
|
||||
def __init__(self, color):
|
||||
c = color if isinstance(color, (list, tuple)) and len(color) == 3 else [255, 255, 255]
|
||||
self._color = (int(c[0]), int(c[1]), int(c[2]))
|
||||
|
||||
def get_value(self) -> float:
|
||||
r, g, b = self._color
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
|
||||
|
||||
def get_color(self) -> tuple:
|
||||
return self._color
|
||||
|
||||
def update_source(self, source) -> None:
|
||||
c = getattr(source, "color", [255, 255, 255])
|
||||
self._color = (
|
||||
(int(c[0]), int(c[1]), int(c[2]))
|
||||
if isinstance(c, (list, tuple)) and len(c) == 3
|
||||
else (255, 255, 255)
|
||||
)
|
||||
|
||||
|
||||
class AnimatedColorValueStream(ValueStream):
|
||||
"""Cycles through a list of colors over time."""
|
||||
|
||||
def __init__(self, colors, speed=10.0, easing="linear"):
|
||||
self._colors = [
|
||||
(int(c[0]), int(c[1]), int(c[2]))
|
||||
for c in (colors or [[255, 0, 0], [0, 255, 0], [0, 0, 255]])
|
||||
if isinstance(c, (list, tuple)) and len(c) == 3
|
||||
] or [(255, 0, 0), (0, 255, 0), (0, 0, 255)]
|
||||
self._speed = max(0.01, float(speed))
|
||||
self._easing = easing
|
||||
self._start_time = 0.0
|
||||
|
||||
def start(self) -> None:
|
||||
self._start_time = time.monotonic()
|
||||
|
||||
def get_value(self) -> float:
|
||||
r, g, b = self.get_color()
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
|
||||
|
||||
def get_color(self) -> tuple:
|
||||
elapsed = time.monotonic() - self._start_time
|
||||
cycle_time = 60.0 / self._speed
|
||||
n = len(self._colors)
|
||||
if self._easing == "step":
|
||||
idx = int((elapsed / cycle_time * n) % n)
|
||||
return self._colors[idx]
|
||||
phase = (elapsed / cycle_time * n) % n
|
||||
idx = int(phase)
|
||||
frac = phase - idx
|
||||
c1 = self._colors[idx % n]
|
||||
c2 = self._colors[(idx + 1) % n]
|
||||
return (
|
||||
int(c1[0] + (c2[0] - c1[0]) * frac),
|
||||
int(c1[1] + (c2[1] - c1[1]) * frac),
|
||||
int(c1[2] + (c2[2] - c1[2]) * frac),
|
||||
)
|
||||
|
||||
def update_source(self, source) -> None:
|
||||
raw = getattr(source, "colors", None)
|
||||
if raw and isinstance(raw, list):
|
||||
self._colors = [
|
||||
(int(c[0]), int(c[1]), int(c[2]))
|
||||
for c in raw
|
||||
if isinstance(c, (list, tuple)) and len(c) == 3
|
||||
] or self._colors
|
||||
self._speed = max(0.01, float(getattr(source, "speed", self._speed)))
|
||||
self._easing = getattr(source, "easing", self._easing)
|
||||
|
||||
|
||||
class AdaptiveTimeColorValueStream(ValueStream):
|
||||
"""Interpolates RGB colors along a 24-hour schedule."""
|
||||
|
||||
def __init__(self, schedule):
|
||||
self._schedule = schedule or []
|
||||
self._parsed = self._parse(self._schedule)
|
||||
|
||||
@staticmethod
|
||||
def _parse(schedule):
|
||||
result = []
|
||||
for entry in schedule:
|
||||
t_str = entry.get("time", "00:00")
|
||||
parts = t_str.split(":")
|
||||
minutes = int(parts[0]) * 60 + int(parts[1]) if len(parts) >= 2 else 0
|
||||
c = entry.get("color", [255, 255, 255])
|
||||
color = (
|
||||
(int(c[0]), int(c[1]), int(c[2]))
|
||||
if isinstance(c, (list, tuple)) and len(c) == 3
|
||||
else (255, 255, 255)
|
||||
)
|
||||
result.append((minutes, color))
|
||||
result.sort(key=lambda x: x[0])
|
||||
return result
|
||||
|
||||
def get_value(self) -> float:
|
||||
r, g, b = self.get_color()
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
|
||||
|
||||
def get_color(self) -> tuple:
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now()
|
||||
cur = now.hour * 60 + now.minute + now.second / 60.0
|
||||
pts = self._parsed
|
||||
if not pts:
|
||||
return (255, 255, 255)
|
||||
if len(pts) == 1:
|
||||
return pts[0][1]
|
||||
before = pts[-1]
|
||||
after = pts[0]
|
||||
for i, (t, c) in enumerate(pts):
|
||||
if t <= cur:
|
||||
before = (t, c)
|
||||
after = pts[(i + 1) % len(pts)]
|
||||
b_t, b_c = before
|
||||
a_t, a_c = after
|
||||
span = a_t - b_t
|
||||
if span <= 0:
|
||||
span += 1440
|
||||
elapsed = cur - b_t
|
||||
if elapsed < 0:
|
||||
elapsed += 1440
|
||||
frac = max(0.0, min(1.0, elapsed / span if span > 0 else 0.0))
|
||||
return (
|
||||
int(b_c[0] + (a_c[0] - b_c[0]) * frac),
|
||||
int(b_c[1] + (a_c[1] - b_c[1]) * frac),
|
||||
int(b_c[2] + (a_c[2] - b_c[2]) * frac),
|
||||
)
|
||||
|
||||
def update_source(self, source) -> None:
|
||||
self._schedule = getattr(source, "schedule", self._schedule)
|
||||
self._parsed = self._parse(self._schedule)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HA Entity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HAEntityValueStream(ValueStream):
|
||||
"""Reads a numeric value from a Home Assistant entity state or attribute.
|
||||
|
||||
Normalizes the raw HA value to [0, 1] using the configured min/max range,
|
||||
applies EMA smoothing.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ha_source_id: str,
|
||||
entity_id: str,
|
||||
attribute: str = "",
|
||||
min_ha_value: float = 0.0,
|
||||
max_ha_value: float = 100.0,
|
||||
smoothing: float = 0.0,
|
||||
ha_manager: Optional[Any] = None,
|
||||
):
|
||||
self._ha_source_id = ha_source_id
|
||||
self._entity_id = entity_id
|
||||
self._attribute = attribute
|
||||
self._min_ha = min_ha_value
|
||||
self._max_ha = max_ha_value
|
||||
self._smoothing = smoothing
|
||||
self._ha_manager = ha_manager
|
||||
self._prev_value: Optional[float] = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._ha_manager and self._ha_source_id:
|
||||
try:
|
||||
self._ha_manager.acquire(self._ha_source_id)
|
||||
logger.info(
|
||||
"HAEntityValueStream started (ha=%s, entity=%s, attr=%s)",
|
||||
self._ha_source_id,
|
||||
self._entity_id,
|
||||
self._attribute or "<state>",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("HAEntityValueStream failed to acquire HA runtime: %s", e)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._ha_manager and self._ha_source_id:
|
||||
try:
|
||||
self._ha_manager.release(self._ha_source_id)
|
||||
except Exception as e:
|
||||
logger.warning("HAEntityValueStream failed to release HA runtime: %s", e)
|
||||
self._prev_value = None
|
||||
|
||||
def get_value(self) -> float:
|
||||
if self._ha_manager is None:
|
||||
return self._prev_value if self._prev_value is not None else 0.0
|
||||
|
||||
state = self._ha_manager.get_state(self._ha_source_id, self._entity_id)
|
||||
if state is None:
|
||||
return self._prev_value if self._prev_value is not None else 0.0
|
||||
|
||||
# Extract raw value from state or attribute
|
||||
try:
|
||||
if self._attribute:
|
||||
attrs = getattr(state, "attributes", {})
|
||||
raw_str = attrs.get(self._attribute, getattr(state, "state", "0"))
|
||||
else:
|
||||
raw_str = getattr(state, "state", "0")
|
||||
raw = float(raw_str)
|
||||
except (ValueError, TypeError):
|
||||
return self._prev_value if self._prev_value is not None else 0.0
|
||||
|
||||
# Normalize to [0, 1]
|
||||
ha_range = self._max_ha - self._min_ha
|
||||
if abs(ha_range) < 1e-9:
|
||||
normalized = 0.5
|
||||
else:
|
||||
normalized = (raw - self._min_ha) / ha_range
|
||||
|
||||
normalized = max(0.0, min(1.0, normalized))
|
||||
|
||||
# EMA smoothing
|
||||
if self._smoothing > 0.0 and self._prev_value is not None:
|
||||
normalized = self._smoothing * self._prev_value + (1.0 - self._smoothing) * normalized
|
||||
|
||||
self._prev_value = normalized
|
||||
return normalized
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
from wled_controller.storage.value_source import HAEntityValueSource
|
||||
|
||||
if not isinstance(source, HAEntityValueSource):
|
||||
return
|
||||
|
||||
old_ha_source = self._ha_source_id
|
||||
self._ha_source_id = source.ha_source_id
|
||||
self._entity_id = source.entity_id
|
||||
self._attribute = source.attribute
|
||||
self._min_ha = source.min_ha_value
|
||||
self._max_ha = source.max_ha_value
|
||||
self._smoothing = source.smoothing
|
||||
|
||||
# If HA source changed, swap runtime
|
||||
if source.ha_source_id != old_ha_source and self._ha_manager:
|
||||
try:
|
||||
self._ha_manager.release(old_ha_source)
|
||||
self._ha_manager.acquire(self._ha_source_id)
|
||||
except Exception as e:
|
||||
logger.warning("HAEntityValueStream failed to swap HA runtime: %s", e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gradient Map
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GradientMapValueStream(ValueStream):
|
||||
"""Maps a float value source through a color gradient.
|
||||
|
||||
Acquires a float-returning ValueStream, reads its value (0..1), and
|
||||
interpolates the color at that position in the gradient stops resolved
|
||||
from a gradient entity.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
value_source_id: str,
|
||||
gradient_id: str = "",
|
||||
easing: str = "linear",
|
||||
value_stream_manager: Optional["ValueStreamManager"] = None,
|
||||
gradient_store: Optional[Any] = None,
|
||||
):
|
||||
self._value_source_id = value_source_id
|
||||
self._gradient_id = gradient_id
|
||||
self._easing = easing
|
||||
self._vsm = value_stream_manager
|
||||
self._gradient_store = gradient_store
|
||||
self._inner_stream: Optional[ValueStream] = None
|
||||
self._stops: list = []
|
||||
self._resolve_gradient()
|
||||
|
||||
def _resolve_gradient(self) -> None:
|
||||
"""Resolve gradient_id to stops list."""
|
||||
if self._gradient_id and self._gradient_store:
|
||||
try:
|
||||
gradient = self._gradient_store.get(self._gradient_id)
|
||||
self._stops = sorted(
|
||||
gradient.stops if hasattr(gradient, "stops") else [],
|
||||
key=lambda s: (
|
||||
s.get("position", 0) if isinstance(s, dict) else getattr(s, "position", 0)
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"GradientMapValueStream failed to resolve gradient %s: %s", self._gradient_id, e
|
||||
)
|
||||
self._stops = []
|
||||
else:
|
||||
self._stops = []
|
||||
|
||||
def start(self) -> None:
|
||||
if self._vsm and self._value_source_id:
|
||||
try:
|
||||
self._inner_stream = self._vsm.acquire(self._value_source_id)
|
||||
logger.info(
|
||||
"GradientMapValueStream acquired inner stream %s", self._value_source_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("GradientMapValueStream failed to acquire inner stream: %s", e)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._vsm and self._value_source_id:
|
||||
self._vsm.release(self._value_source_id)
|
||||
self._inner_stream = None
|
||||
|
||||
def get_value(self) -> float:
|
||||
r, g, b = self.get_color()
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
|
||||
|
||||
def get_color(self) -> tuple:
|
||||
if self._inner_stream is None:
|
||||
return (128, 128, 128)
|
||||
|
||||
t = max(0.0, min(1.0, self._inner_stream.get_value()))
|
||||
stops = self._stops
|
||||
if not stops:
|
||||
return (128, 128, 128)
|
||||
if len(stops) == 1:
|
||||
c = stops[0].get("color", [128, 128, 128])
|
||||
return (int(c[0]), int(c[1]), int(c[2]))
|
||||
|
||||
# Find surrounding stops
|
||||
left = stops[0]
|
||||
right = stops[-1]
|
||||
for i in range(len(stops) - 1):
|
||||
if stops[i].get("position", 0) <= t <= stops[i + 1].get("position", 1):
|
||||
left = stops[i]
|
||||
right = stops[i + 1]
|
||||
break
|
||||
|
||||
lp = left.get("position", 0)
|
||||
rp = right.get("position", 1)
|
||||
lc = left.get("color", [0, 0, 0])
|
||||
rc = right.get("color", [255, 255, 255])
|
||||
|
||||
span = rp - lp
|
||||
if span <= 0 or self._easing == "step":
|
||||
return (int(lc[0]), int(lc[1]), int(lc[2]))
|
||||
|
||||
frac = (t - lp) / span
|
||||
return (
|
||||
int(lc[0] + (rc[0] - lc[0]) * frac),
|
||||
int(lc[1] + (rc[1] - lc[1]) * frac),
|
||||
int(lc[2] + (rc[2] - lc[2]) * frac),
|
||||
)
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
from wled_controller.storage.value_source import GradientMapValueSource
|
||||
|
||||
if not isinstance(source, GradientMapValueSource):
|
||||
return
|
||||
|
||||
self._gradient_id = source.gradient_id
|
||||
self._easing = source.easing
|
||||
self._resolve_gradient()
|
||||
|
||||
# If input source changed, swap
|
||||
if source.value_source_id != self._value_source_id and self._vsm:
|
||||
old_id = self._value_source_id
|
||||
self._value_source_id = source.value_source_id
|
||||
if self._inner_stream is not None:
|
||||
self._vsm.release(old_id)
|
||||
try:
|
||||
self._inner_stream = self._vsm.acquire(self._value_source_id)
|
||||
except Exception as e:
|
||||
logger.warning("GradientMapValueStream failed to swap inner stream: %s", e)
|
||||
self._inner_stream = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS Extract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CSSExtractValueStream(ValueStream):
|
||||
"""Extracts a single color by averaging a LED range from a color strip stream.
|
||||
|
||||
Acquires a ColorStripStream, reads the LED colors, and averages the
|
||||
specified range to produce a single RGB color.
|
||||
"""
|
||||
|
||||
_DEFAULT_LED_COUNT = 60
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
color_strip_source_id: str,
|
||||
led_start: int = 0,
|
||||
led_end: int = -1,
|
||||
css_stream_manager: Optional["ColorStripStreamManager"] = None,
|
||||
):
|
||||
self._css_source_id = color_strip_source_id
|
||||
self._led_start = led_start
|
||||
self._led_end = led_end
|
||||
self._cssm = css_stream_manager
|
||||
self._css_stream = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._cssm and self._css_source_id:
|
||||
try:
|
||||
self._css_stream = self._cssm.acquire(
|
||||
self._css_source_id,
|
||||
self._DEFAULT_LED_COUNT,
|
||||
)
|
||||
logger.info("CSSExtractValueStream acquired CSS stream %s", self._css_source_id)
|
||||
except Exception as e:
|
||||
logger.warning("CSSExtractValueStream failed to acquire CSS stream: %s", e)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._cssm and self._css_source_id:
|
||||
try:
|
||||
self._cssm.release(self._css_source_id, self._DEFAULT_LED_COUNT)
|
||||
except Exception as e:
|
||||
logger.warning("CSSExtractValueStream failed to release CSS stream: %s", e)
|
||||
self._css_stream = None
|
||||
|
||||
def get_value(self) -> float:
|
||||
r, g, b = self.get_color()
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
|
||||
|
||||
def get_color(self) -> tuple:
|
||||
if self._css_stream is None:
|
||||
return (0, 0, 0)
|
||||
|
||||
colors = self._css_stream.get_colors()
|
||||
if colors is None or len(colors) == 0:
|
||||
return (0, 0, 0)
|
||||
|
||||
n = len(colors)
|
||||
start = max(0, self._led_start)
|
||||
end = self._led_end if self._led_end >= 0 else n
|
||||
end = min(end, n)
|
||||
|
||||
if start >= end:
|
||||
return (0, 0, 0)
|
||||
|
||||
segment = colors[start:end]
|
||||
if len(segment) == 0:
|
||||
return (0, 0, 0)
|
||||
|
||||
# Average the segment — colors is numpy array (N, 3) uint8
|
||||
if isinstance(segment, np.ndarray):
|
||||
avg = segment.mean(axis=0)
|
||||
return (int(avg[0]), int(avg[1]), int(avg[2]))
|
||||
|
||||
# Fallback for list
|
||||
r = sum(c[0] for c in segment) // len(segment)
|
||||
g = sum(c[1] for c in segment) // len(segment)
|
||||
b = sum(c[2] for c in segment) // len(segment)
|
||||
return (r, g, b)
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
from wled_controller.storage.value_source import CSSExtractValueSource
|
||||
|
||||
if not isinstance(source, CSSExtractValueSource):
|
||||
return
|
||||
|
||||
self._led_start = source.led_start
|
||||
self._led_end = source.led_end
|
||||
|
||||
# If CSS source changed, swap
|
||||
if source.color_strip_source_id != self._css_source_id and self._cssm:
|
||||
old_id = self._css_source_id
|
||||
self._css_source_id = source.color_strip_source_id
|
||||
if self._css_stream is not None:
|
||||
try:
|
||||
self._cssm.release(old_id, self._DEFAULT_LED_COUNT)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._css_stream = self._cssm.acquire(
|
||||
self._css_source_id,
|
||||
self._DEFAULT_LED_COUNT,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("CSSExtractValueStream failed to swap CSS stream: %s", e)
|
||||
self._css_stream = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ValueStreamManager:
|
||||
"""Owns running ValueStream instances, shared and ref-counted by vs_id.
|
||||
|
||||
@@ -623,14 +1140,20 @@ class ValueStreamManager:
|
||||
audio_source_store: Optional["AudioSourceStore"] = None,
|
||||
live_stream_manager: Optional["LiveStreamManager"] = None,
|
||||
audio_template_store=None,
|
||||
ha_manager: Optional["HomeAssistantManager"] = None,
|
||||
css_stream_manager: Optional["ColorStripStreamManager"] = None,
|
||||
gradient_store: Optional[Any] = None,
|
||||
):
|
||||
self._value_source_store = value_source_store
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
self._audio_source_store = audio_source_store
|
||||
self._live_stream_manager = live_stream_manager
|
||||
self._audio_template_store = audio_template_store
|
||||
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
|
||||
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
|
||||
self._ha_manager = ha_manager
|
||||
self._css_stream_manager = css_stream_manager
|
||||
self._gradient_store = gradient_store
|
||||
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
|
||||
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
|
||||
|
||||
def acquire(self, vs_id: str) -> ValueStream:
|
||||
"""Get or create a shared ValueStream for the given ValueSource.
|
||||
@@ -698,8 +1221,14 @@ class ValueStreamManager:
|
||||
AdaptiveValueSource,
|
||||
AnimatedValueSource,
|
||||
AudioValueSource,
|
||||
CSSExtractValueSource,
|
||||
DaylightValueSource,
|
||||
GradientMapValueSource,
|
||||
HAEntityValueSource,
|
||||
StaticValueSource,
|
||||
StaticColorValueSource,
|
||||
AnimatedColorValueSource,
|
||||
AdaptiveTimeColorValueSource,
|
||||
)
|
||||
|
||||
if isinstance(source, StaticValueSource):
|
||||
@@ -753,5 +1282,47 @@ class ValueStreamManager:
|
||||
max_value=source.max_value,
|
||||
)
|
||||
|
||||
# Color streams
|
||||
if isinstance(source, StaticColorValueSource):
|
||||
return StaticColorValueStream(color=source.color)
|
||||
|
||||
if isinstance(source, AnimatedColorValueSource):
|
||||
return AnimatedColorValueStream(
|
||||
colors=source.colors,
|
||||
speed=source.speed,
|
||||
easing=source.easing,
|
||||
)
|
||||
|
||||
if isinstance(source, AdaptiveTimeColorValueSource):
|
||||
return AdaptiveTimeColorValueStream(schedule=source.schedule)
|
||||
|
||||
if isinstance(source, HAEntityValueSource):
|
||||
return HAEntityValueStream(
|
||||
ha_source_id=source.ha_source_id,
|
||||
entity_id=source.entity_id,
|
||||
attribute=source.attribute,
|
||||
min_ha_value=source.min_ha_value,
|
||||
max_ha_value=source.max_ha_value,
|
||||
smoothing=source.smoothing,
|
||||
ha_manager=self._ha_manager,
|
||||
)
|
||||
|
||||
if isinstance(source, GradientMapValueSource):
|
||||
return GradientMapValueStream(
|
||||
value_source_id=source.value_source_id,
|
||||
gradient_id=source.gradient_id,
|
||||
easing=source.easing,
|
||||
value_stream_manager=self,
|
||||
gradient_store=self._gradient_store,
|
||||
)
|
||||
|
||||
if isinstance(source, CSSExtractValueSource):
|
||||
return CSSExtractValueStream(
|
||||
color_strip_source_id=source.color_strip_source_id,
|
||||
led_start=source.led_start,
|
||||
led_end=source.led_end,
|
||||
css_stream_manager=self._css_stream_manager,
|
||||
)
|
||||
|
||||
# Fallback
|
||||
return StaticValueStream(value=1.0)
|
||||
|
||||
@@ -845,6 +845,32 @@ textarea:focus-visible {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.type-picker-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.type-picker-tab {
|
||||
flex: 1;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2, #1e1e2e);
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.type-picker-tab:hover {
|
||||
background: var(--surface-3, #2a2a3e);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
.type-picker-tab.active {
|
||||
background: var(--primary-color, #63b3ed);
|
||||
color: #fff;
|
||||
border-color: var(--primary-color, #63b3ed);
|
||||
}
|
||||
|
||||
/* ── Entity Palette (command-palette style selector) ─────── */
|
||||
|
||||
.entity-palette-overlay {
|
||||
|
||||
@@ -14,9 +14,16 @@
|
||||
gap: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.dashboard-section-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-section-chevron {
|
||||
font-size: 0.6rem;
|
||||
@@ -453,13 +460,12 @@
|
||||
left: 12px;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: calc(100% - 4px);
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -480,9 +486,12 @@
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: var(--hover-bg);
|
||||
padding: 1px 5px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.2px;
|
||||
vertical-align: middle;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.perf-chart-label .color-picker-swatch {
|
||||
|
||||
@@ -148,6 +148,8 @@ import {
|
||||
editValueSource, cloneValueSource, deleteValueSource, onValueSourceTypeChange,
|
||||
onDaylightVSRealTimeChange,
|
||||
addSchedulePoint,
|
||||
addAnimatedColor, removeAnimatedColor,
|
||||
addColorSchedulePoint, removeColorSchedulePoint,
|
||||
testValueSource, closeTestValueSourceModal,
|
||||
} from './features/value-sources.ts';
|
||||
|
||||
@@ -468,6 +470,10 @@ Object.assign(window, {
|
||||
onValueSourceTypeChange,
|
||||
onDaylightVSRealTimeChange,
|
||||
addSchedulePoint,
|
||||
addAnimatedColor,
|
||||
removeAnimatedColor,
|
||||
addColorSchedulePoint,
|
||||
removeColorSchedulePoint,
|
||||
testValueSource,
|
||||
closeTestValueSourceModal,
|
||||
|
||||
|
||||
@@ -71,11 +71,14 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P
|
||||
await new Promise(r => setTimeout(r, 500 * 2 ** attempt));
|
||||
continue;
|
||||
}
|
||||
// Final attempt failed — show user-facing error
|
||||
const errMsg = (err as Error)?.name === 'AbortError'
|
||||
? t('api.error.timeout')
|
||||
: t('api.error.network');
|
||||
showToast(errMsg, 'error');
|
||||
// Final attempt failed — show toast only if the connection
|
||||
// overlay isn't already covering the screen
|
||||
if (_serverOnline !== false) {
|
||||
const errMsg = (err as Error)?.name === 'AbortError'
|
||||
? t('api.error.timeout')
|
||||
: t('api.error.network');
|
||||
showToast(errMsg, 'error');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* BindableColorWidget — a color picker that can optionally bind to a color value source.
|
||||
*
|
||||
* Renders a color input with a small toggle button. When toggled to
|
||||
* "bound" mode, shows an EntitySelect color value source picker.
|
||||
* Emits a BindableColor value: plain [R,G,B] (static) or {color, source_id} (bound).
|
||||
*/
|
||||
|
||||
import type { BindableColor } from '../types.ts';
|
||||
import { bindableColor, bindableColorSourceId } from '../types.ts';
|
||||
import { EntitySelect } from './entity-palette.ts';
|
||||
import { getValueSourceIcon } from './icons.ts';
|
||||
import { t } from './i18n.ts';
|
||||
|
||||
export interface BindableColorOpts {
|
||||
container: HTMLElement;
|
||||
default: number[];
|
||||
/** Only show color value sources (return_type="color") */
|
||||
valueSources: () => Array<{ id: string; name: string; source_type: string; return_type?: string }>;
|
||||
onChange?: (value: BindableColor) => void;
|
||||
idPrefix?: string;
|
||||
noneLabel?: string;
|
||||
}
|
||||
|
||||
let _widgetCounter = 0;
|
||||
|
||||
export class BindableColorWidget {
|
||||
private _container: HTMLElement;
|
||||
private _opts: BindableColorOpts;
|
||||
private _id: string;
|
||||
private _bound: boolean = false;
|
||||
private _staticColor: number[];
|
||||
private _sourceId: string = '';
|
||||
private _entitySelect: EntitySelect | null = null;
|
||||
|
||||
private _colorRow!: HTMLElement;
|
||||
private _vsRow!: HTMLElement;
|
||||
private _colorInput!: HTMLInputElement;
|
||||
private _toggleBtn!: HTMLButtonElement;
|
||||
private _select!: HTMLSelectElement;
|
||||
|
||||
constructor(opts: BindableColorOpts) {
|
||||
this._opts = opts;
|
||||
this._container = opts.container;
|
||||
this._staticColor = [...opts.default];
|
||||
this._id = opts.idPrefix || `bcw-${++_widgetCounter}`;
|
||||
this._render();
|
||||
}
|
||||
|
||||
private _rgbToHex(rgb: number[]): string {
|
||||
return '#' + rgb.map(c => Math.max(0, Math.min(255, c)).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
private _hexToRgb(hex: string): number[] {
|
||||
const m = hex.replace('#', '').match(/.{2}/g);
|
||||
return m ? m.map(h => parseInt(h, 16)) : [255, 255, 255];
|
||||
}
|
||||
|
||||
private _render(): void {
|
||||
const id = this._id;
|
||||
const hex = this._rgbToHex(this._staticColor);
|
||||
|
||||
const toggleSvg = `<svg class="icon icon-xs" viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>`;
|
||||
|
||||
const colorHtml = `<div class="bindable-slider-row" id="${id}-color-row">
|
||||
<input type="color" id="${id}-color" value="${hex}">
|
||||
<button type="button" class="bindable-toggle" id="${id}-toggle" title="${t('bindable.toggle')}">${toggleSvg}</button>
|
||||
</div>`;
|
||||
|
||||
const vsHtml = `<div class="bindable-vs-row" id="${id}-vs-row" style="display:none">
|
||||
<select id="${id}-select"></select>
|
||||
<button type="button" class="bindable-toggle bindable-toggle--active" id="${id}-untoggle" title="${t('bindable.toggle')}">${toggleSvg}</button>
|
||||
</div>`;
|
||||
|
||||
this._container.innerHTML = colorHtml + vsHtml;
|
||||
|
||||
this._colorRow = document.getElementById(`${id}-color-row`)!;
|
||||
this._vsRow = document.getElementById(`${id}-vs-row`)!;
|
||||
this._colorInput = document.getElementById(`${id}-color`) as HTMLInputElement;
|
||||
this._select = document.getElementById(`${id}-select`) as HTMLSelectElement;
|
||||
this._toggleBtn = document.getElementById(`${id}-toggle`) as HTMLButtonElement;
|
||||
|
||||
this._colorInput.addEventListener('input', () => {
|
||||
this._staticColor = this._hexToRgb(this._colorInput.value);
|
||||
this._fireChange();
|
||||
});
|
||||
|
||||
this._toggleBtn.addEventListener('click', () => this._setMode(true));
|
||||
document.getElementById(`${id}-untoggle`)!.addEventListener('click', () => this._setMode(false));
|
||||
}
|
||||
|
||||
private _setMode(bound: boolean): void {
|
||||
this._bound = bound;
|
||||
this._colorRow.style.display = bound ? 'none' : '';
|
||||
this._vsRow.style.display = bound ? '' : 'none';
|
||||
if (bound) {
|
||||
this._populateVsSelect();
|
||||
} else {
|
||||
this._sourceId = '';
|
||||
if (this._entitySelect) { this._entitySelect.destroy(); this._entitySelect = null; }
|
||||
}
|
||||
this._fireChange();
|
||||
}
|
||||
|
||||
private _populateVsSelect(): void {
|
||||
// Filter to only color value sources
|
||||
const sources = this._opts.valueSources().filter(vs => vs.return_type === 'color');
|
||||
|
||||
this._select.innerHTML = `<option value="">${this._opts.noneLabel || t('bindable.none')}</option>` +
|
||||
sources.map(vs =>
|
||||
`<option value="${vs.id}"${vs.id === this._sourceId ? ' selected' : ''}>${vs.name}</option>`
|
||||
).join('');
|
||||
|
||||
if (this._entitySelect) this._entitySelect.destroy();
|
||||
this._entitySelect = new EntitySelect({
|
||||
target: this._select,
|
||||
getItems: () => sources.map(vs => ({
|
||||
value: vs.id,
|
||||
label: vs.name,
|
||||
icon: getValueSourceIcon(vs.source_type),
|
||||
desc: vs.source_type,
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
allowNone: true,
|
||||
noneLabel: t('bindable.none'),
|
||||
onChange: (value: string) => {
|
||||
this._sourceId = value;
|
||||
this._fireChange();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _fireChange(): void {
|
||||
if (this._opts.onChange) {
|
||||
this._opts.onChange(this.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
getValue(): BindableColor {
|
||||
if (this._bound && this._sourceId) {
|
||||
return { color: [...this._staticColor], source_id: this._sourceId };
|
||||
}
|
||||
return [...this._staticColor];
|
||||
}
|
||||
|
||||
setValue(bc: BindableColor | undefined): void {
|
||||
this._staticColor = bindableColor(bc, this._opts.default);
|
||||
this._sourceId = bindableColorSourceId(bc);
|
||||
this._bound = !!this._sourceId;
|
||||
|
||||
this._colorInput.value = this._rgbToHex(this._staticColor);
|
||||
this._colorRow.style.display = this._bound ? 'none' : '';
|
||||
this._vsRow.style.display = this._bound ? '' : 'none';
|
||||
|
||||
if (this._bound) {
|
||||
this._populateVsSelect();
|
||||
}
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
if (this._bound) this._populateVsSelect();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this._entitySelect) { this._entitySelect.destroy(); this._entitySelect = null; }
|
||||
this._container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,10 @@ const CONNECTION_MAP: ConnectionEntry[] = [
|
||||
// Value sources
|
||||
{ targetKind: 'value_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
||||
{ targetKind: 'value_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
||||
{ targetKind: 'value_source', field: 'ha_source_id', sourceKind: 'ha_source', edgeType: 'ha', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
||||
{ targetKind: 'value_source', field: 'value_source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
||||
{ targetKind: 'value_source', field: 'gradient_id', sourceKind: 'gradient', edgeType: 'gradient', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
||||
{ targetKind: 'value_source', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
||||
|
||||
// Color strip sources
|
||||
{ targetKind: 'color_strip_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
@@ -77,6 +81,11 @@ const CONNECTION_MAP: ConnectionEntry[] = [
|
||||
{ targetKind: 'color_strip_source', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
// HA light target transition binding
|
||||
{ targetKind: 'output_target', field: 'transition.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
// ── BindableColor value source edges (CSS color properties) ──
|
||||
{ targetKind: 'color_strip_source', field: 'color.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'color_peak.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'fallback_color.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'default_color.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
|
||||
// ── Nested fields (not drag-editable in V1) ──
|
||||
{ targetKind: 'color_strip_source', field: 'layer.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
|
||||
|
||||
@@ -359,6 +359,13 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
||||
const vsId = bindableSourceId((s as any)[prop]);
|
||||
if (vsId) addEdge(vsId, s.id, `${prop}.source_id`);
|
||||
}
|
||||
// BindableColor value source edges
|
||||
for (const prop of ['color', 'color_peak', 'fallback_color', 'default_color'] as const) {
|
||||
const raw = (s as any)[prop];
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw) && raw.source_id) {
|
||||
addEdge(raw.source_id, s.id, `${prop}.source_id`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Output target edges
|
||||
|
||||
@@ -287,17 +287,36 @@ export class IconSelect {
|
||||
* the overlay closes and `onPick(value)` is called. Clicking the backdrop
|
||||
* or pressing Escape dismisses without picking.
|
||||
*/
|
||||
export function showTypePicker({ title, items, onPick }: { title: string; items: IconSelectItem[]; onPick: (value: string) => void }) {
|
||||
export interface FilterTab {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function showTypePicker({ title, items, onPick, filterTabs, onFilterChange }: {
|
||||
title: string;
|
||||
items: IconSelectItem[];
|
||||
onPick: (value: string) => void;
|
||||
filterTabs?: FilterTab[];
|
||||
onFilterChange?: (key: string) => IconSelectItem[];
|
||||
}) {
|
||||
const showFilter = items.length > 9;
|
||||
|
||||
// Build cells
|
||||
const cells = items.map(item =>
|
||||
`<div class="icon-select-cell" data-value="${item.value}" data-search="${(item.label + ' ' + (item.desc || '')).toLowerCase()}">
|
||||
<span class="icon-select-cell-icon">${item.icon}</span>
|
||||
<span class="icon-select-cell-label">${item.label}</span>
|
||||
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
function buildCells(cellItems: IconSelectItem[]): string {
|
||||
return cellItems.map(item =>
|
||||
`<div class="icon-select-cell" data-value="${item.value}" data-search="${(item.label + ' ' + (item.desc || '')).toLowerCase()}">
|
||||
<span class="icon-select-cell-icon">${item.icon}</span>
|
||||
<span class="icon-select-cell-label">${item.label}</span>
|
||||
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Build filter tabs HTML
|
||||
const tabsHtml = filterTabs && filterTabs.length > 0
|
||||
? `<div class="type-picker-tabs">${filterTabs.map((tab, i) =>
|
||||
`<button class="type-picker-tab${i === 0 ? ' active' : ''}" data-filter-key="${tab.key}">${tab.label}</button>`
|
||||
).join('')}</div>`
|
||||
: '';
|
||||
|
||||
// Create overlay
|
||||
const overlay = document.createElement('div');
|
||||
@@ -305,26 +324,52 @@ export function showTypePicker({ title, items, onPick }: { title: string; items:
|
||||
overlay.innerHTML = `
|
||||
<div class="type-picker-dialog">
|
||||
<div class="type-picker-title">${title}</div>
|
||||
${tabsHtml}
|
||||
${showFilter ? '<input class="type-picker-filter" type="text" placeholder="Filter…" autocomplete="off">' : ''}
|
||||
<div class="icon-select-grid">${cells}</div>
|
||||
<div class="icon-select-grid">${buildCells(items)}</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey); };
|
||||
|
||||
const grid = overlay.querySelector('.icon-select-grid') as HTMLElement;
|
||||
|
||||
function bindCellClicks() {
|
||||
grid.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
if (cell.classList.contains('disabled')) return;
|
||||
close();
|
||||
onPick((cell as HTMLElement).dataset.value!);
|
||||
});
|
||||
});
|
||||
}
|
||||
bindCellClicks();
|
||||
|
||||
// Filter tabs logic
|
||||
if (filterTabs && onFilterChange) {
|
||||
overlay.querySelectorAll('.type-picker-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
overlay.querySelectorAll('.type-picker-tab').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const key = (btn as HTMLElement).dataset.filterKey!;
|
||||
const newItems = onFilterChange(key);
|
||||
grid.innerHTML = buildCells(newItems);
|
||||
bindCellClicks();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Filter logic
|
||||
if (showFilter) {
|
||||
const input = overlay.querySelector('.type-picker-filter') as HTMLInputElement;
|
||||
const allCells = overlay.querySelectorAll('.icon-select-cell');
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.toLowerCase().trim();
|
||||
allCells.forEach(cell => {
|
||||
grid.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
const el = cell as HTMLElement;
|
||||
const match = !q || el.dataset.search!.includes(q);
|
||||
el.classList.toggle('disabled', !match);
|
||||
});
|
||||
});
|
||||
// Auto-focus filter after animation (skip on touch devices to avoid keyboard popup)
|
||||
requestAnimationFrame(() => setTimeout(() => desktopFocus(input), 200));
|
||||
}
|
||||
|
||||
@@ -339,15 +384,6 @@ export function showTypePicker({ title, items, onPick }: { title: string; items:
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
||||
// Cell clicks
|
||||
overlay.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
if (cell.classList.contains('disabled')) return;
|
||||
close();
|
||||
onPick((cell as HTMLElement).dataset.value!);
|
||||
});
|
||||
});
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => overlay.classList.add('open'));
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ const _valueSourceTypeIcons = {
|
||||
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
|
||||
adaptive_time: _svg(P.clock), adaptive_scene: _svg(P.cloudSun),
|
||||
daylight: _svg(P.sun),
|
||||
static_color: _svg(P.palette), animated_color: _svg(P.refreshCw),
|
||||
adaptive_time_color: _svg(P.clock),
|
||||
ha_entity: _svg(P.home), gradient_map: _svg(P.rainbow),
|
||||
css_extract: _svg(P.droplets),
|
||||
};
|
||||
const _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume2), band_extract: _svg(P.activity) };
|
||||
const _deviceTypeIcons = {
|
||||
@@ -298,6 +302,8 @@ export const ICON_TARGET_ICON = _svg(P.target);
|
||||
export const ICON_TRENDING_UP = _svg(P.trendingUp);
|
||||
export const ICON_ACTIVITY = _svg(P.activity);
|
||||
export const ICON_MOVE_VERTICAL = _svg(P.moveVertical);
|
||||
export const ICON_HOME = _svg(P.home);
|
||||
export const ICON_DROPLETS = _svg(P.droplets);
|
||||
export const ICON_SUN_DIM = _svg(P.sunDim);
|
||||
export const ICON_CAMERA = _svg(P.camera);
|
||||
export const ICON_WRENCH = _svg(P.wrench);
|
||||
|
||||
@@ -296,10 +296,12 @@ export const automationsCacheObj = new DataCache<Automation[]>({
|
||||
});
|
||||
automationsCacheObj.subscribe(v => { _automationsCache = v; });
|
||||
|
||||
export let _cachedColorStripSources: ColorStripSource[] = [];
|
||||
export const colorStripSourcesCache = new DataCache<ColorStripSource[]>({
|
||||
endpoint: '/color-strip-sources',
|
||||
extractData: json => json.sources || [],
|
||||
});
|
||||
colorStripSourcesCache.subscribe(v => { _cachedColorStripSources = v; });
|
||||
|
||||
export const csptCache = new DataCache<ColorStripProcessingTemplate[]>({
|
||||
endpoint: '/color-strip-processing-templates',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* Tab indicator — large semi-transparent blurred icon on the right side
|
||||
* of the viewport, reflecting the currently active tab.
|
||||
*
|
||||
* Visible whenever any background effect is active (Noise Field, shader,
|
||||
* CSS-based). Hidden only when no background effect is selected.
|
||||
*/
|
||||
|
||||
const TAB_SVGS = {
|
||||
@@ -23,14 +26,21 @@ function _ensureEl() {
|
||||
return _el;
|
||||
}
|
||||
|
||||
/** Check if any background effect is currently active. */
|
||||
function _isBgActive(): boolean {
|
||||
const html = document.documentElement;
|
||||
return html.getAttribute('data-bg-anim') === 'on'
|
||||
|| html.hasAttribute('data-bg-effect');
|
||||
}
|
||||
|
||||
export function updateTabIndicator(tabName) {
|
||||
if (tabName === _currentTab) return;
|
||||
_currentTab = tabName;
|
||||
const svg = TAB_SVGS[tabName];
|
||||
if (!svg) return;
|
||||
|
||||
// Respect the dynamic background toggle — hide when bg-anim is off
|
||||
if (document.documentElement.getAttribute('data-bg-anim') !== 'on') {
|
||||
// Hide when no background effect is active
|
||||
if (!_isBgActive()) {
|
||||
const el = _ensureEl();
|
||||
el.classList.remove('tab-indicator-visible');
|
||||
return;
|
||||
@@ -48,11 +58,10 @@ export function updateTabIndicator(tabName) {
|
||||
export function initTabIndicator() {
|
||||
_ensureEl();
|
||||
|
||||
// Listen for bg-anim toggle to show/hide the indicator
|
||||
// Listen for bg-anim and bg-effect changes to show/hide the indicator
|
||||
new MutationObserver(() => {
|
||||
const on = document.documentElement.getAttribute('data-bg-anim') === 'on';
|
||||
const el = _ensureEl();
|
||||
if (!on) {
|
||||
if (!_isBgActive()) {
|
||||
el.classList.remove('tab-indicator-visible');
|
||||
} else if (_currentTab) {
|
||||
// Re-trigger show for the current tab
|
||||
@@ -60,7 +69,7 @@ export function initTabIndicator() {
|
||||
_currentTab = null;
|
||||
updateTabIndicator(prev);
|
||||
}
|
||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-bg-anim'] });
|
||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-bg-anim', 'data-bg-effect'] });
|
||||
|
||||
// Set initial tab from current active button
|
||||
const active = document.querySelector('.tab-btn.active') as HTMLElement | null;
|
||||
|
||||
@@ -107,6 +107,8 @@ export function closeLightbox(event?: Event) {
|
||||
if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
|
||||
img.src = '';
|
||||
img.style.display = '';
|
||||
const content = lightbox.querySelector('.lightbox-content') as HTMLElement | null;
|
||||
if (content) content.style.width = ''; // Reset any custom width
|
||||
document.getElementById('lightbox-stats')!.style.display = 'none';
|
||||
const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
|
||||
@@ -29,6 +29,7 @@ interface MonitorRect {
|
||||
|
||||
interface CalibrationState {
|
||||
cssId: string | null;
|
||||
sourceType: string;
|
||||
lines: CalibrationLine[];
|
||||
monitors: MonitorRect[];
|
||||
pictureSources: PictureSource[];
|
||||
@@ -94,6 +95,7 @@ const LINE_THICKNESS_PX = 6;
|
||||
|
||||
let _state: CalibrationState = {
|
||||
cssId: null,
|
||||
sourceType: 'picture_advanced',
|
||||
lines: [],
|
||||
monitors: [],
|
||||
pictureSources: [],
|
||||
@@ -122,6 +124,7 @@ class AdvancedCalibrationModal extends Modal {
|
||||
onForceClose(): void {
|
||||
if (_lineSourceEntitySelect) { _lineSourceEntitySelect.destroy(); _lineSourceEntitySelect = null; }
|
||||
_state.cssId = null;
|
||||
_state.sourceType = 'picture_advanced';
|
||||
_state.lines = [];
|
||||
_state.totalLedCount = 0;
|
||||
_state.selectedLine = -1;
|
||||
@@ -144,6 +147,7 @@ export async function showAdvancedCalibration(cssId: string): Promise<void> {
|
||||
const psList = psResp.ok ? ((await psResp.json()).streams || []) : [];
|
||||
|
||||
_state.cssId = cssId;
|
||||
_state.sourceType = source.source_type || 'picture_advanced';
|
||||
_state.pictureSources = psList;
|
||||
_state.totalLedCount = source.led_count || 0;
|
||||
|
||||
@@ -219,7 +223,7 @@ export async function saveAdvancedCalibration(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ calibration }),
|
||||
body: JSON.stringify({ source_type: _state.sourceType, calibration }),
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
|
||||
@@ -391,7 +391,7 @@ export function applyBgEffect(id: string): void {
|
||||
/** Restore saved presets on page load. Called from init. */
|
||||
export function initAppearance(): void {
|
||||
_activeStyleId = localStorage.getItem(LS_STYLE_PRESET) || 'default';
|
||||
_activeBgEffectId = localStorage.getItem(LS_BG_EFFECT) || 'none';
|
||||
_activeBgEffectId = localStorage.getItem(LS_BG_EFFECT) || 'noise';
|
||||
|
||||
// Apply style preset silently (without toast)
|
||||
const preset = STYLE_PRESETS.find(p => p.id === _activeStyleId);
|
||||
|
||||
@@ -241,9 +241,10 @@ export async function showCSSCalibration(cssId: any) {
|
||||
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
||||
const calibration: Calibration = source.calibration || {} as Calibration;
|
||||
|
||||
// Set CSS mode — clear device-id, set css-id
|
||||
// Set CSS mode — clear device-id, set css-id and source type
|
||||
(document.getElementById('calibration-device-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('calibration-css-id') as HTMLInputElement).value = cssId;
|
||||
(document.getElementById('calibration-css-source-type') as HTMLInputElement).value = source.source_type || 'picture';
|
||||
|
||||
// Populate device picker for edge test
|
||||
const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement;
|
||||
@@ -931,9 +932,10 @@ export async function saveCalibration() {
|
||||
try {
|
||||
let response;
|
||||
if (cssMode) {
|
||||
const cssSourceType = (document.getElementById('calibration-css-source-type') as HTMLInputElement).value || 'picture';
|
||||
response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ calibration, led_count: declaredLedCount }),
|
||||
body: JSON.stringify({ source_type: cssSourceType, calibration, led_count: declaredLedCount }),
|
||||
});
|
||||
} else {
|
||||
response = await fetchWithAuth(`/devices/${deviceId}/calibration`, {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { attachNotificationAppPicker, NotificationAppPalette } from '../core/pro
|
||||
import { _cachedAssets, _cachedValueSources, assetsCache } from '../core/state.ts';
|
||||
import { getBaseOrigin } from './settings.ts';
|
||||
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
|
||||
import { BindableColorWidget } from '../core/bindable-color.ts';
|
||||
|
||||
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
@@ -38,6 +39,33 @@ export function notificationGetRawAppOverrides() {
|
||||
let _notificationEffectIconSelect: any = null;
|
||||
let _notificationFilterModeIconSelect: any = null;
|
||||
let _notificationDurationWidget: BindableScalarWidget | null = null;
|
||||
let _notificationDefaultColorWidget: BindableColorWidget | null = null;
|
||||
let _notificationVolumeWidget: BindableScalarWidget | null = null;
|
||||
|
||||
function _ensureNotificationVolumeWidget(): BindableScalarWidget {
|
||||
if (!_notificationVolumeWidget) {
|
||||
_notificationVolumeWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('css-editor-notification-volume-container')!,
|
||||
min: 0, max: 1, step: 0.05, default: 1.0,
|
||||
idPrefix: 'css-editor-notification-volume',
|
||||
format: (v) => `${Math.round(v * 100)}%`,
|
||||
valueSources: () => _cachedValueSources,
|
||||
});
|
||||
}
|
||||
return _notificationVolumeWidget;
|
||||
}
|
||||
|
||||
function _ensureNotificationDefaultColorWidget(): BindableColorWidget {
|
||||
if (!_notificationDefaultColorWidget) {
|
||||
_notificationDefaultColorWidget = new BindableColorWidget({
|
||||
container: document.getElementById('css-editor-notification-default-color-container')!,
|
||||
default: [255, 255, 255],
|
||||
idPrefix: 'css-editor-notification-default-color',
|
||||
valueSources: () => _cachedValueSources,
|
||||
});
|
||||
}
|
||||
return _notificationDefaultColorWidget;
|
||||
}
|
||||
|
||||
function _ensureNotificationDurationWidget(): BindableScalarWidget {
|
||||
if (!_notificationDurationWidget) {
|
||||
@@ -54,6 +82,8 @@ function _ensureNotificationDurationWidget(): BindableScalarWidget {
|
||||
|
||||
export function destroyNotificationDurationWidget(): void {
|
||||
if (_notificationDurationWidget) { _notificationDurationWidget.destroy(); _notificationDurationWidget = null; }
|
||||
if (_notificationDefaultColorWidget) { _notificationDefaultColorWidget.destroy(); _notificationDefaultColorWidget = null; }
|
||||
if (_notificationVolumeWidget) { _notificationVolumeWidget.destroy(); _notificationVolumeWidget = null; }
|
||||
}
|
||||
|
||||
export function getNotificationDurationValue(): number | { value: number; source_id: string } {
|
||||
@@ -64,6 +94,22 @@ export function getNotificationDurationSnapshot(): string {
|
||||
return _notificationDurationWidget ? JSON.stringify(_notificationDurationWidget.getValue()) : '1500';
|
||||
}
|
||||
|
||||
export function getNotificationDefaultColorValue(): any {
|
||||
return _notificationDefaultColorWidget ? _notificationDefaultColorWidget.getValue() : [255, 255, 255];
|
||||
}
|
||||
|
||||
export function getNotificationVolumeValue(): any {
|
||||
return _notificationVolumeWidget ? _notificationVolumeWidget.getValue() : 1.0;
|
||||
}
|
||||
|
||||
export function getNotificationVolumeSnapshot(): string {
|
||||
return _notificationVolumeWidget ? JSON.stringify(_notificationVolumeWidget.getValue()) : '1.0';
|
||||
}
|
||||
|
||||
export function getNotificationDefaultColorSnapshot(): string {
|
||||
return _notificationDefaultColorWidget ? JSON.stringify(_notificationDefaultColorWidget.getValue()) : '[255,255,255]';
|
||||
}
|
||||
|
||||
export function ensureNotificationEffectIconSelect() {
|
||||
const sel = document.getElementById('css-editor-notification-effect') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
@@ -374,7 +420,7 @@ export async function loadNotificationState(css: any) {
|
||||
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
|
||||
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
|
||||
_ensureNotificationDurationWidget().setValue(css.duration_ms ?? 1500);
|
||||
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = css.default_color || '#ffffff';
|
||||
_ensureNotificationDefaultColorWidget().setValue(css.default_color);
|
||||
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = css.app_filter_mode || 'off';
|
||||
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(css.app_filter_mode || 'off');
|
||||
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = (css.app_filter_list || []).join('\n');
|
||||
@@ -390,9 +436,7 @@ export async function loadNotificationState(css: any) {
|
||||
if (soundSel) soundSel.value = css.sound_asset_id || '';
|
||||
ensureNotifSoundEntitySelect();
|
||||
if (_notifSoundEntitySelect && css.sound_asset_id) _notifSoundEntitySelect.setValue(css.sound_asset_id);
|
||||
const volPct = Math.round((css.sound_volume ?? 1.0) * 100);
|
||||
(document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = volPct as any;
|
||||
(document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = `${volPct}%`;
|
||||
_ensureNotificationVolumeWidget().setValue(css.sound_volume ?? 1.0);
|
||||
|
||||
// Unified per-app overrides (merge app_colors + app_sounds)
|
||||
_notificationAppOverrides = _mergeOverrides(css.app_colors || {}, css.app_sounds || {});
|
||||
@@ -406,7 +450,7 @@ export async function resetNotificationState() {
|
||||
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
|
||||
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
|
||||
_ensureNotificationDurationWidget().setValue(1500);
|
||||
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = '#ffffff';
|
||||
_ensureNotificationDefaultColorWidget().setValue([255, 255, 255]);
|
||||
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = 'off';
|
||||
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off');
|
||||
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = '';
|
||||
@@ -418,8 +462,7 @@ export async function resetNotificationState() {
|
||||
_populateSoundOptions(soundSel);
|
||||
if (soundSel) soundSel.value = '';
|
||||
ensureNotifSoundEntitySelect();
|
||||
(document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = 100 as any;
|
||||
(document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = '100%';
|
||||
_ensureNotificationVolumeWidget().setValue(1.0);
|
||||
|
||||
// Clear overrides
|
||||
_notificationAppOverrides = [];
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
|
||||
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
|
||||
import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue } from './color-strips-notification.ts';
|
||||
import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue, getNotificationVolumeValue } from './color-strips-notification.ts';
|
||||
|
||||
/* ── Preview config builder ───────────────────────────────────── */
|
||||
|
||||
@@ -55,7 +55,7 @@ function _collectPreviewConfig() {
|
||||
app_filter_list: filterList,
|
||||
app_colors: notificationGetAppColorsDict(),
|
||||
sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null,
|
||||
sound_volume: parseInt((document.getElementById('css-editor-notification-volume') as HTMLInputElement).value) / 100,
|
||||
sound_volume: getNotificationVolumeValue(),
|
||||
app_sounds: notificationGetAppSoundsDict(),
|
||||
};
|
||||
}
|
||||
@@ -167,8 +167,11 @@ function _testKeyColorsSource(sourceId: string) {
|
||||
// Show lightbox with spinner
|
||||
const lightbox = document.getElementById('image-lightbox')!;
|
||||
const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null;
|
||||
const content = lightbox.querySelector('.lightbox-content') as HTMLElement | null;
|
||||
if (content) content.style.width = '90vw'; // Fill viewport for KC preview
|
||||
const img = document.getElementById('lightbox-image') as HTMLImageElement;
|
||||
img.src = '';
|
||||
img.style.display = 'none'; // Hide until first frame arrives
|
||||
if (spinner) spinner.style.display = '';
|
||||
document.getElementById('lightbox-stats')!.style.display = 'none';
|
||||
lightbox.classList.add('active');
|
||||
|
||||
@@ -18,11 +18,12 @@ import {
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import type { ColorStripSource } from '../types.ts';
|
||||
import { bindableValue } from '../types.ts';
|
||||
import { bindableValue, bindableColor } from '../types.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
|
||||
import { BindableColorWidget } from '../core/bindable-color.ts';
|
||||
import { getBaseOrigin } from './settings.ts';
|
||||
import {
|
||||
rgbArrayToHex, hexToRgbArray,
|
||||
@@ -42,6 +43,8 @@ import {
|
||||
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||
loadNotificationState, resetNotificationState, showNotificationEndpoint,
|
||||
destroyNotificationDurationWidget, getNotificationDurationValue, getNotificationDurationSnapshot,
|
||||
getNotificationDefaultColorValue, getNotificationDefaultColorSnapshot,
|
||||
getNotificationVolumeValue, getNotificationVolumeSnapshot,
|
||||
} from './color-strips-notification.ts';
|
||||
|
||||
// Re-export for app.js window global bindings
|
||||
@@ -72,6 +75,12 @@ class CSSEditorModal extends Modal {
|
||||
if (_candlelightWindWidget) { _candlelightWindWidget.destroy(); _candlelightWindWidget = null; }
|
||||
if (_weatherSpeedWidget) { _weatherSpeedWidget.destroy(); _weatherSpeedWidget = null; }
|
||||
if (_weatherTempInfluenceWidget) { _weatherTempInfluenceWidget.destroy(); _weatherTempInfluenceWidget = null; }
|
||||
if (_staticColorWidget) { _staticColorWidget.destroy(); _staticColorWidget = null; }
|
||||
if (_effectColorWidget) { _effectColorWidget.destroy(); _effectColorWidget = null; }
|
||||
if (_audioColorWidget) { _audioColorWidget.destroy(); _audioColorWidget = null; }
|
||||
if (_audioColorPeakWidget) { _audioColorPeakWidget.destroy(); _audioColorPeakWidget = null; }
|
||||
if (_apiInputFallbackColorWidget) { _apiInputFallbackColorWidget.destroy(); _apiInputFallbackColorWidget = null; }
|
||||
if (_candlelightColorWidget) { _candlelightColorWidget.destroy(); _candlelightColorWidget = null; }
|
||||
destroyNotificationDurationWidget();
|
||||
if (_kcPictureSourceEntitySelect) { _kcPictureSourceEntitySelect.destroy(); _kcPictureSourceEntitySelect = null; }
|
||||
if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; }
|
||||
@@ -88,14 +97,14 @@ class CSSEditorModal extends Modal {
|
||||
picture_source: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
|
||||
interpolation: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
|
||||
smoothing: _smoothingWidget ? JSON.stringify(_smoothingWidget.getValue()) : '0.3',
|
||||
color: (document.getElementById('css-editor-color') as HTMLInputElement).value,
|
||||
color: _staticColorWidget ? JSON.stringify(_staticColorWidget.getValue()) : '[]',
|
||||
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
|
||||
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
|
||||
animation_type: (document.getElementById('css-editor-animation-type') as HTMLInputElement).value,
|
||||
cycle_colors: JSON.stringify(_colorCycleColors),
|
||||
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
|
||||
effect_palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
|
||||
effect_color: (document.getElementById('css-editor-effect-color') as HTMLInputElement).value,
|
||||
effect_color: _effectColorWidget ? JSON.stringify(_effectColorWidget.getValue()) : '[]',
|
||||
effect_intensity: _effectIntensityWidget ? JSON.stringify(_effectIntensityWidget.getValue()) : '1.0',
|
||||
effect_scale: _effectScaleWidget ? JSON.stringify(_effectScaleWidget.getValue()) : '1.0',
|
||||
effect_mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
|
||||
@@ -106,16 +115,16 @@ class CSSEditorModal extends Modal {
|
||||
audio_sensitivity: _audioSensitivityWidget ? JSON.stringify(_audioSensitivityWidget.getValue()) : '1.0',
|
||||
audio_smoothing: _audioSmoothingWidget ? JSON.stringify(_audioSmoothingWidget.getValue()) : '0.3',
|
||||
audio_palette: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
|
||||
audio_color: (document.getElementById('css-editor-audio-color') as HTMLInputElement).value,
|
||||
audio_color_peak: (document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value,
|
||||
audio_color: _audioColorWidget ? JSON.stringify(_audioColorWidget.getValue()) : '[]',
|
||||
audio_color_peak: _audioColorPeakWidget ? JSON.stringify(_audioColorPeakWidget.getValue()) : '[]',
|
||||
audio_mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked,
|
||||
api_input_fallback_color: (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value,
|
||||
api_input_fallback_color: _apiInputFallbackColorWidget ? JSON.stringify(_apiInputFallbackColorWidget.getValue()) : '[]',
|
||||
api_input_timeout: _apiInputTimeoutWidget ? JSON.stringify(_apiInputTimeoutWidget.getValue()) : '5.0',
|
||||
api_input_interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLInputElement).value,
|
||||
notification_os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
|
||||
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
|
||||
notification_duration: getNotificationDurationSnapshot(),
|
||||
notification_default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
|
||||
notification_default_color: getNotificationDefaultColorSnapshot(),
|
||||
notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
||||
notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value,
|
||||
notification_app_overrides: JSON.stringify(notificationGetRawAppOverrides()),
|
||||
@@ -123,7 +132,7 @@ class CSSEditorModal extends Modal {
|
||||
daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value,
|
||||
daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
|
||||
daylight_latitude: (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value,
|
||||
candlelight_color: (document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value,
|
||||
candlelight_color: _candlelightColorWidget ? JSON.stringify(_candlelightColorWidget.getValue()) : '[]',
|
||||
candlelight_intensity: _candlelightIntensityWidget ? JSON.stringify(_candlelightIntensityWidget.getValue()) : '1.0',
|
||||
candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value,
|
||||
candlelight_speed: _candlelightSpeedWidget ? JSON.stringify(_candlelightSpeedWidget.getValue()) : '1.0',
|
||||
@@ -152,6 +161,14 @@ let _candlelightWindWidget: BindableScalarWidget | null = null;
|
||||
let _weatherSpeedWidget: BindableScalarWidget | null = null;
|
||||
let _weatherTempInfluenceWidget: BindableScalarWidget | null = null;
|
||||
|
||||
// ── BindableColorWidget instances for CSS editor ──
|
||||
let _staticColorWidget: BindableColorWidget | null = null;
|
||||
let _effectColorWidget: BindableColorWidget | null = null;
|
||||
let _audioColorWidget: BindableColorWidget | null = null;
|
||||
let _audioColorPeakWidget: BindableColorWidget | null = null;
|
||||
let _apiInputFallbackColorWidget: BindableColorWidget | null = null;
|
||||
let _candlelightColorWidget: BindableColorWidget | null = null;
|
||||
|
||||
// ── EntitySelect instances for CSS editor ──
|
||||
let _cssPictureSourceEntitySelect: any = null;
|
||||
let _cssAudioSourceEntitySelect: any = null;
|
||||
@@ -204,6 +221,7 @@ async function configureKCRegions(sourceId: string): Promise<void> {
|
||||
if (!showPatternTemplateEditor) return;
|
||||
showPatternTemplateEditor(null, null, {
|
||||
rects: rects.map((r: any) => ({ ...r })),
|
||||
pictureSourceId: source.picture_source_id || '',
|
||||
onSave: async (newRects: any[]) => {
|
||||
// Save rectangles back to the CSS source
|
||||
try {
|
||||
@@ -286,9 +304,9 @@ const CSS_ALL_SECTION_IDS = [...new Set(Object.values(CSS_SECTION_MAP))];
|
||||
|
||||
const CSS_TYPE_SETUP: Record<string, () => void> = {
|
||||
processed: () => _populateProcessedSelectors(),
|
||||
effect: () => { _ensureEffectTypeIconSelect(); _ensureEffectPaletteIconSelect(); onEffectTypeChange(); },
|
||||
audio: () => { _ensureAudioVizIconSelect(); _ensureAudioPaletteIconSelect(); onAudioVizChange(); _loadAudioSources(); },
|
||||
gradient: () => { _ensureGradientPresetIconSelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); },
|
||||
effect: () => { _ensureEffectTypeIconSelect(); _ensureEffectPaletteEntitySelect(); onEffectTypeChange(); },
|
||||
audio: () => { _ensureAudioVizIconSelect(); _ensureAudioPaletteEntitySelect(); onAudioVizChange(); _loadAudioSources(); },
|
||||
gradient: () => { _ensureGradientPresetEntitySelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); },
|
||||
notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); },
|
||||
candlelight: () => _ensureCandleTypeIconSelect(),
|
||||
weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); },
|
||||
@@ -491,10 +509,10 @@ function _syncDaylightSpeedVisibility() {
|
||||
let _animationTypeIconSelect: any = null;
|
||||
let _interpolationIconSelect: any = null;
|
||||
let _effectTypeIconSelect: any = null;
|
||||
let _effectPaletteIconSelect: any = null;
|
||||
let _audioPaletteIconSelect: any = null;
|
||||
let _effectPaletteEntitySelect: EntitySelect | null = null;
|
||||
let _audioPaletteEntitySelect: EntitySelect | null = null;
|
||||
let _audioVizIconSelect: any = null;
|
||||
let _gradientPresetIconSelect: any = null;
|
||||
let _gradientPresetEntitySelect: EntitySelect | null = null;
|
||||
let _gradientEasingIconSelect: any = null;
|
||||
let _candleTypeIconSelect: any = null;
|
||||
let _apiInputInterpolationIconSelect: any = null;
|
||||
@@ -681,6 +699,78 @@ function _ensureWeatherTempInfluenceWidget(): BindableScalarWidget {
|
||||
return _weatherTempInfluenceWidget;
|
||||
}
|
||||
|
||||
function _ensureStaticColorWidget(): BindableColorWidget {
|
||||
if (!_staticColorWidget) {
|
||||
_staticColorWidget = new BindableColorWidget({
|
||||
container: document.getElementById('css-editor-color-container')!,
|
||||
default: [255, 255, 255],
|
||||
valueSources: () => _cachedValueSources,
|
||||
idPrefix: 'css-editor-color',
|
||||
});
|
||||
}
|
||||
return _staticColorWidget;
|
||||
}
|
||||
|
||||
function _ensureEffectColorWidget(): BindableColorWidget {
|
||||
if (!_effectColorWidget) {
|
||||
_effectColorWidget = new BindableColorWidget({
|
||||
container: document.getElementById('css-editor-effect-color-container')!,
|
||||
default: [255, 80, 0],
|
||||
valueSources: () => _cachedValueSources,
|
||||
idPrefix: 'css-editor-effect-color',
|
||||
});
|
||||
}
|
||||
return _effectColorWidget;
|
||||
}
|
||||
|
||||
function _ensureAudioColorWidget(): BindableColorWidget {
|
||||
if (!_audioColorWidget) {
|
||||
_audioColorWidget = new BindableColorWidget({
|
||||
container: document.getElementById('css-editor-audio-color-container')!,
|
||||
default: [0, 255, 0],
|
||||
valueSources: () => _cachedValueSources,
|
||||
idPrefix: 'css-editor-audio-color',
|
||||
});
|
||||
}
|
||||
return _audioColorWidget;
|
||||
}
|
||||
|
||||
function _ensureAudioColorPeakWidget(): BindableColorWidget {
|
||||
if (!_audioColorPeakWidget) {
|
||||
_audioColorPeakWidget = new BindableColorWidget({
|
||||
container: document.getElementById('css-editor-audio-color-peak-container')!,
|
||||
default: [255, 0, 0],
|
||||
valueSources: () => _cachedValueSources,
|
||||
idPrefix: 'css-editor-audio-color-peak',
|
||||
});
|
||||
}
|
||||
return _audioColorPeakWidget;
|
||||
}
|
||||
|
||||
function _ensureApiInputFallbackColorWidget(): BindableColorWidget {
|
||||
if (!_apiInputFallbackColorWidget) {
|
||||
_apiInputFallbackColorWidget = new BindableColorWidget({
|
||||
container: document.getElementById('css-editor-api-input-fallback-color-container')!,
|
||||
default: [0, 0, 0],
|
||||
valueSources: () => _cachedValueSources,
|
||||
idPrefix: 'css-editor-api-input-fallback-color',
|
||||
});
|
||||
}
|
||||
return _apiInputFallbackColorWidget;
|
||||
}
|
||||
|
||||
function _ensureCandlelightColorWidget(): BindableColorWidget {
|
||||
if (!_candlelightColorWidget) {
|
||||
_candlelightColorWidget = new BindableColorWidget({
|
||||
container: document.getElementById('css-editor-candlelight-color-container')!,
|
||||
default: [255, 147, 41],
|
||||
valueSources: () => _cachedValueSources,
|
||||
idPrefix: 'css-editor-candlelight-color',
|
||||
});
|
||||
}
|
||||
return _candlelightColorWidget;
|
||||
}
|
||||
|
||||
function _ensureApiInputInterpolationIconSelect() {
|
||||
const sel = document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
@@ -714,13 +804,17 @@ function _ensureEffectTypeIconSelect() {
|
||||
_effectTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 });
|
||||
}
|
||||
|
||||
function _ensureEffectPaletteIconSelect() {
|
||||
function _ensureEffectPaletteEntitySelect() {
|
||||
const sel = document.getElementById('css-editor-effect-palette') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const items = _buildGradientEntityItems();
|
||||
_syncSelectOptions(sel, items);
|
||||
if (_effectPaletteIconSelect) { _effectPaletteIconSelect.updateItems(items); return; }
|
||||
_effectPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2, searchable: true, searchPlaceholder: t('palette.search') });
|
||||
if (_effectPaletteEntitySelect) { _effectPaletteEntitySelect.refresh(); return; }
|
||||
_effectPaletteEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: _buildGradientEntityItems,
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
}
|
||||
|
||||
function _ensureGradientEasingIconSelect() {
|
||||
@@ -749,13 +843,17 @@ function _ensureCandleTypeIconSelect() {
|
||||
_candleTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 });
|
||||
}
|
||||
|
||||
function _ensureAudioPaletteIconSelect() {
|
||||
function _ensureAudioPaletteEntitySelect() {
|
||||
const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const items = _buildGradientEntityItems();
|
||||
_syncSelectOptions(sel, items);
|
||||
if (_audioPaletteIconSelect) { _audioPaletteIconSelect.updateItems(items); return; }
|
||||
_audioPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2, searchable: true, searchPlaceholder: t('palette.search') });
|
||||
if (_audioPaletteEntitySelect) { _audioPaletteEntitySelect.refresh(); return; }
|
||||
_audioPaletteEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: _buildGradientEntityItems,
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
}
|
||||
|
||||
function _ensureAudioVizIconSelect() {
|
||||
@@ -788,21 +886,29 @@ function _syncSelectOptions(sel: HTMLSelectElement, items: Array<{ value: string
|
||||
}
|
||||
}
|
||||
|
||||
function _ensureGradientPresetIconSelect() {
|
||||
function _ensureGradientPresetEntitySelect() {
|
||||
const sel = document.getElementById('css-editor-gradient-preset') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const items = _buildGradientEntityItems();
|
||||
_syncSelectOptions(sel, items);
|
||||
if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; }
|
||||
_gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3, searchable: true, searchPlaceholder: t('palette.search') });
|
||||
if (_gradientPresetEntitySelect) { _gradientPresetEntitySelect.refresh(); return; }
|
||||
_gradientPresetEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: _buildGradientEntityItems,
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Rebuild the gradient picker after entity changes. */
|
||||
export function refreshGradientPresetPicker() {
|
||||
const items = _buildGradientEntityItems();
|
||||
if (_gradientPresetIconSelect) _gradientPresetIconSelect.updateItems(items);
|
||||
if (_effectPaletteIconSelect) _effectPaletteIconSelect.updateItems(items);
|
||||
if (_audioPaletteIconSelect) _audioPaletteIconSelect.updateItems(items);
|
||||
// Re-sync select options before refreshing entity selects
|
||||
for (const selId of ['css-editor-gradient-preset', 'css-editor-effect-palette', 'css-editor-audio-palette']) {
|
||||
const sel = document.getElementById(selId) as HTMLSelectElement | null;
|
||||
if (sel) _syncSelectOptions(sel, _buildGradientEntityItems());
|
||||
}
|
||||
if (_gradientPresetEntitySelect) _gradientPresetEntitySelect.refresh();
|
||||
if (_effectPaletteEntitySelect) _effectPaletteEntitySelect.refresh();
|
||||
if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.refresh();
|
||||
}
|
||||
|
||||
/** Render the user-created gradient list below the save button. */
|
||||
@@ -1198,9 +1304,9 @@ function _loadAudioState(css: any) {
|
||||
|
||||
const audioGradientId = css.gradient_id || 'gr_builtin_rainbow';
|
||||
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId;
|
||||
if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue(audioGradientId);
|
||||
(document.getElementById('css-editor-audio-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [0, 255, 0]);
|
||||
(document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value = rgbArrayToHex(css.color_peak || [255, 0, 0]);
|
||||
if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.setValue(audioGradientId);
|
||||
_ensureAudioColorWidget().setValue(css.color);
|
||||
_ensureAudioColorPeakWidget().setValue(css.color_peak);
|
||||
(document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = css.mirror || false;
|
||||
|
||||
// Set audio source selector
|
||||
@@ -1216,9 +1322,9 @@ function _resetAudioState() {
|
||||
_ensureAudioSensitivityWidget().setValue(1.0);
|
||||
_ensureAudioSmoothingWidget().setValue(0.3);
|
||||
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow';
|
||||
if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue('gr_builtin_rainbow');
|
||||
(document.getElementById('css-editor-audio-color') as HTMLInputElement).value = '#00ff00';
|
||||
(document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value = '#ff0000';
|
||||
if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.setValue('gr_builtin_rainbow');
|
||||
_ensureAudioColorWidget().setValue([0, 255, 0]);
|
||||
_ensureAudioColorPeakWidget().setValue([255, 0, 0]);
|
||||
(document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = false;
|
||||
}
|
||||
|
||||
@@ -1238,7 +1344,7 @@ const NON_PICTURE_TYPES = new Set([
|
||||
|
||||
const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
static: (source, { clockBadge, animBadge }) => {
|
||||
const hexColor = rgbArrayToHex(source.color!);
|
||||
const hexColor = rgbArrayToHex(bindableColor(source.color, [255,255,255]));
|
||||
return `
|
||||
<span class="stream-card-prop" title="${t('color_strip.static_color')}">
|
||||
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
|
||||
@@ -1320,7 +1426,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
`;
|
||||
},
|
||||
api_input: (source) => {
|
||||
const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]);
|
||||
const fbColor = rgbArrayToHex(bindableColor(source.fallback_color, [0, 0, 0]));
|
||||
const timeoutVal = bindableValue(source.timeout, 5.0).toFixed(1);
|
||||
const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear';
|
||||
return `
|
||||
@@ -1334,13 +1440,14 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
notification: (source) => {
|
||||
const effectLabel = t('color_strip.notification.effect.' + (source.notification_effect || 'flash')) || source.notification_effect || 'flash';
|
||||
const durationVal = source.duration_ms || 1500;
|
||||
const defColor = source.default_color || '#FFFFFF';
|
||||
const defColorRgb = bindableColor(source.default_color as any, [255, 255, 255]);
|
||||
const defColorHex = rgbArrayToHex(defColorRgb);
|
||||
const appCount = source.app_colors ? Object.keys(source.app_colors).length : 0;
|
||||
return `
|
||||
<span class="stream-card-prop" title="${t('color_strip.notification.effect')}">${ICON_BELL} ${escapeHtml(effectLabel)}</span>
|
||||
<span class="stream-card-prop" title="${t('color_strip.notification.duration')}">${ICON_TIMER} ${durationVal}ms</span>
|
||||
<span class="stream-card-prop" title="${t('color_strip.notification.default_color')}">
|
||||
<span style="display:inline-block;width:14px;height:14px;background:${defColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${defColor.toUpperCase()}
|
||||
<span style="display:inline-block;width:14px;height:14px;background:${defColorHex};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${defColorHex.toUpperCase()}
|
||||
</span>
|
||||
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
|
||||
`;
|
||||
@@ -1354,7 +1461,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
`;
|
||||
},
|
||||
candlelight: (source, { clockBadge }) => {
|
||||
const hexColor = rgbArrayToHex(source.color || [255, 147, 41]);
|
||||
const hexColor = rgbArrayToHex(bindableColor(source.color, [255, 147, 41]));
|
||||
const numCandles = source.num_candles ?? 3;
|
||||
return `
|
||||
<span class="stream-card-prop" title="${t('color_strip.candlelight.color')}">
|
||||
@@ -1539,17 +1646,17 @@ function _autoGenerateCSSName() {
|
||||
const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...args: any[]) => any; getPayload: (name: any) => any }> = {
|
||||
static: {
|
||||
load(css) {
|
||||
(document.getElementById('css-editor-color') as HTMLInputElement).value = rgbArrayToHex(css.color);
|
||||
_ensureStaticColorWidget().setValue(css.color);
|
||||
_loadAnimationState(css.animation);
|
||||
},
|
||||
reset() {
|
||||
(document.getElementById('css-editor-color') as HTMLInputElement).value = '#ffffff';
|
||||
_ensureStaticColorWidget().setValue([255, 255, 255]);
|
||||
_loadAnimationState(null);
|
||||
},
|
||||
getPayload(name) {
|
||||
return {
|
||||
name,
|
||||
color: hexToRgbArray((document.getElementById('css-editor-color') as HTMLInputElement).value),
|
||||
color: _ensureStaticColorWidget().getValue(),
|
||||
animation: _getAnimationPayload(),
|
||||
};
|
||||
},
|
||||
@@ -1573,20 +1680,20 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
gradient: {
|
||||
load(css) {
|
||||
const gradientId = css.gradient_id || '';
|
||||
_ensureGradientPresetIconSelect();
|
||||
_ensureGradientPresetEntitySelect();
|
||||
(document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = gradientId;
|
||||
if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue(gradientId);
|
||||
if (_gradientPresetEntitySelect) _gradientPresetEntitySelect.setValue(gradientId);
|
||||
_loadAnimationState(css.animation);
|
||||
(document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = css.easing || 'linear';
|
||||
if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue(css.easing || 'linear');
|
||||
},
|
||||
reset() {
|
||||
_ensureGradientPresetIconSelect();
|
||||
_ensureGradientPresetEntitySelect();
|
||||
// Default to first gradient
|
||||
const gradients = _getGradients();
|
||||
const defaultId = gradients.length > 0 ? gradients[0].id : '';
|
||||
(document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = defaultId;
|
||||
if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue(defaultId);
|
||||
if (_gradientPresetEntitySelect) _gradientPresetEntitySelect.setValue(defaultId);
|
||||
_loadAnimationState(null);
|
||||
(document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = 'linear';
|
||||
if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue('linear');
|
||||
@@ -1612,8 +1719,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
onEffectTypeChange();
|
||||
const gradientId = css.gradient_id || 'gr_builtin_fire';
|
||||
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = gradientId;
|
||||
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(gradientId);
|
||||
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 80, 0]);
|
||||
if (_effectPaletteEntitySelect) _effectPaletteEntitySelect.setValue(gradientId);
|
||||
_ensureEffectColorWidget().setValue(css.color);
|
||||
_ensureEffectIntensityWidget().setValue(css.intensity ?? 1.0);
|
||||
_ensureEffectScaleWidget().setValue(css.scale ?? 1.0);
|
||||
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false;
|
||||
@@ -1622,8 +1729,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
reset() {
|
||||
(document.getElementById('css-editor-effect-type') as HTMLInputElement).value = 'fire';
|
||||
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = 'gr_builtin_fire';
|
||||
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue('gr_builtin_fire');
|
||||
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = '#ff5000';
|
||||
if (_effectPaletteEntitySelect) _effectPaletteEntitySelect.setValue('gr_builtin_fire');
|
||||
_ensureEffectColorWidget().setValue([255, 80, 0]);
|
||||
_ensureEffectIntensityWidget().setValue(1.0);
|
||||
_ensureEffectScaleWidget().setValue(1.0);
|
||||
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false;
|
||||
@@ -1639,8 +1746,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
};
|
||||
// Meteor/comet/bouncing_ball use a color picker
|
||||
if (['meteor', 'comet', 'bouncing_ball'].includes(payload.effect_type)) {
|
||||
const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value;
|
||||
payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
|
||||
payload.color = _ensureEffectColorWidget().getValue();
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
@@ -1661,8 +1767,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
sensitivity: _ensureAudioSensitivityWidget().getValue(),
|
||||
smoothing: _ensureAudioSmoothingWidget().getValue(),
|
||||
gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
|
||||
color: hexToRgbArray((document.getElementById('css-editor-audio-color') as HTMLInputElement).value),
|
||||
color_peak: hexToRgbArray((document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value),
|
||||
color: _ensureAudioColorWidget().getValue(),
|
||||
color_peak: _ensureAudioColorPeakWidget().getValue(),
|
||||
mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked,
|
||||
};
|
||||
},
|
||||
@@ -1707,25 +1813,23 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
},
|
||||
api_input: {
|
||||
load(css) {
|
||||
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value =
|
||||
rgbArrayToHex(css.fallback_color || [0, 0, 0]);
|
||||
_ensureApiInputFallbackColorWidget().setValue(css.fallback_color);
|
||||
_ensureApiInputTimeoutWidget().setValue(css.timeout ?? 5.0);
|
||||
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = css.interpolation || 'linear';
|
||||
_ensureApiInputInterpolationIconSelect();
|
||||
_showApiInputEndpoints(css.id);
|
||||
},
|
||||
reset() {
|
||||
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value = '#000000';
|
||||
_ensureApiInputFallbackColorWidget().setValue([0, 0, 0]);
|
||||
_ensureApiInputTimeoutWidget().setValue(5.0);
|
||||
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = 'linear';
|
||||
_ensureApiInputInterpolationIconSelect();
|
||||
_showApiInputEndpoints(null);
|
||||
},
|
||||
getPayload(name) {
|
||||
const fbHex = (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value;
|
||||
return {
|
||||
name,
|
||||
fallback_color: hexToRgbArray(fbHex),
|
||||
fallback_color: _ensureApiInputFallbackColorWidget().getValue(),
|
||||
timeout: _ensureApiInputTimeoutWidget().getValue(),
|
||||
interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value,
|
||||
};
|
||||
@@ -1746,12 +1850,12 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
|
||||
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
|
||||
duration_ms: getNotificationDurationValue(),
|
||||
default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
|
||||
default_color: getNotificationDefaultColorValue(),
|
||||
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
||||
app_filter_list: filterList,
|
||||
app_colors: notificationGetAppColorsDict(),
|
||||
sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null,
|
||||
sound_volume: parseInt((document.getElementById('css-editor-notification-volume') as HTMLInputElement).value) / 100,
|
||||
sound_volume: getNotificationVolumeValue(),
|
||||
app_sounds: notificationGetAppSoundsDict(),
|
||||
};
|
||||
},
|
||||
@@ -1788,7 +1892,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
},
|
||||
candlelight: {
|
||||
load(css) {
|
||||
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 147, 41]);
|
||||
_ensureCandlelightColorWidget().setValue(css.color);
|
||||
_ensureCandlelightIntensityWidget().setValue(css.intensity ?? 1.0);
|
||||
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = css.num_candles ?? 3;
|
||||
_ensureCandlelightSpeedWidget().setValue(css.speed ?? 1.0);
|
||||
@@ -1797,7 +1901,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue(css.candle_type || 'default');
|
||||
},
|
||||
reset() {
|
||||
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = '#ff9329';
|
||||
_ensureCandlelightColorWidget().setValue([255, 147, 41]);
|
||||
_ensureCandlelightIntensityWidget().setValue(1.0);
|
||||
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = 3 as any;
|
||||
_ensureCandlelightSpeedWidget().setValue(1.0);
|
||||
@@ -1808,7 +1912,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
getPayload(name) {
|
||||
return {
|
||||
name,
|
||||
color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value),
|
||||
color: _ensureCandlelightColorWidget().getValue(),
|
||||
intensity: _ensureCandlelightIntensityWidget().getValue(),
|
||||
num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3,
|
||||
speed: _ensureCandlelightSpeedWidget().getValue(),
|
||||
@@ -2172,7 +2276,7 @@ export async function saveCSSEditor() {
|
||||
const payload = handler.getPayload(name);
|
||||
if (payload === null) return; // validation error already shown
|
||||
|
||||
if (!cssId) payload.source_type = knownType ? sourceType : 'picture';
|
||||
payload.source_type = knownType ? sourceType : 'picture';
|
||||
|
||||
// Attach clock_id for animated types
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather'];
|
||||
|
||||
@@ -354,10 +354,12 @@ function _sectionHeader(sectionKey: string, label: string, count: number | strin
|
||||
const collapsed = _getCollapsedSections();
|
||||
const isCollapsed = !!collapsed[sectionKey];
|
||||
const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
|
||||
return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}" onclick="toggleDashboardSection('${sectionKey}')">
|
||||
<span class="dashboard-section-chevron"${chevronStyle}>▶</span>
|
||||
${label}
|
||||
<span class="dashboard-section-count">${count}</span>
|
||||
return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}">
|
||||
<span class="dashboard-section-toggle" onclick="toggleDashboardSection('${sectionKey}')">
|
||||
<span class="dashboard-section-chevron"${chevronStyle}>▶</span>
|
||||
${label}
|
||||
<span class="dashboard-section-count">${count}</span>
|
||||
</span>
|
||||
${extraHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -682,7 +682,7 @@ async function _fetchAllEntities(): Promise<Record<string, any>> {
|
||||
devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(),
|
||||
streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(),
|
||||
valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(),
|
||||
outputTargetsCache.fetch(), patternTemplatesCache.fetch(), scenePresetsCache.fetch(),
|
||||
outputTargetsCache.fetch(), patternTemplatesCache.fetch().catch(() => []), scenePresetsCache.fetch(),
|
||||
automationsCacheObj.fetch(), csptCache.fetch(),
|
||||
fetchWithAuth('/output-targets/batch/states').catch(() => null),
|
||||
]);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm, formatUptime } from '../core/ui.ts';
|
||||
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, getColorStripIcon, getValueSourceIcon, getHAEntityIcon } from '../core/icons.ts';
|
||||
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, ICON_SUN, getColorStripIcon, getValueSourceIcon, getHAEntityIcon } from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
@@ -23,7 +23,7 @@ const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
let _haLightTagsInput: TagInput | null = null;
|
||||
let _haSourceEntitySelect: EntitySelect | null = null;
|
||||
let _cssSourceEntitySelect: EntitySelect | null = null;
|
||||
let _brightnessVsEntitySelect: EntitySelect | null = null;
|
||||
let _brightnessWidget: BindableScalarWidget | null = null;
|
||||
let _mappingEntitySelects: EntitySelect[] = [];
|
||||
let _editorCssSources: any[] = [];
|
||||
let _cachedHAEntities: any[] = []; // fetched from selected HA source
|
||||
@@ -39,7 +39,7 @@ class HALightEditorModal extends Modal {
|
||||
if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; }
|
||||
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
|
||||
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
|
||||
if (_brightnessVsEntitySelect) { _brightnessVsEntitySelect.destroy(); _brightnessVsEntitySelect = null; }
|
||||
if (_brightnessWidget) { _brightnessWidget.destroy(); _brightnessWidget = null; }
|
||||
if (_updateRateWidget) { _updateRateWidget.destroy(); _updateRateWidget = null; }
|
||||
if (_transitionWidget) { _transitionWidget.destroy(); _transitionWidget = null; }
|
||||
if (_colorToleranceWidget) { _colorToleranceWidget.destroy(); _colorToleranceWidget = null; }
|
||||
@@ -52,6 +52,7 @@ class HALightEditorModal extends Modal {
|
||||
name: (document.getElementById('ha-light-editor-name') as HTMLInputElement).value,
|
||||
ha_source: (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value,
|
||||
css_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value,
|
||||
brightness: _brightnessWidget ? JSON.stringify(_brightnessWidget.getValue()) : '1.0',
|
||||
update_rate: _updateRateWidget ? JSON.stringify(_updateRateWidget.getValue()) : '2.0',
|
||||
transition: _transitionWidget ? JSON.stringify(_transitionWidget.getValue()) : '0.5',
|
||||
color_tolerance: _colorToleranceWidget ? JSON.stringify(_colorToleranceWidget.getValue()) : '5',
|
||||
@@ -215,6 +216,19 @@ export function removeHALightMapping(btn: HTMLElement): void {
|
||||
|
||||
// ── Bindable scalar widgets ──
|
||||
|
||||
function _ensureBrightnessWidget(): BindableScalarWidget {
|
||||
if (!_brightnessWidget) {
|
||||
_brightnessWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('ha-light-editor-brightness-container')!,
|
||||
min: 0, max: 1, step: 0.05, default: 1.0,
|
||||
idPrefix: 'ha-light-editor-brightness',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(2),
|
||||
});
|
||||
}
|
||||
return _brightnessWidget;
|
||||
}
|
||||
|
||||
function _ensureUpdateRateWidget(): BindableScalarWidget {
|
||||
if (!_updateRateWidget) {
|
||||
_updateRateWidget = new BindableScalarWidget({
|
||||
@@ -322,6 +336,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
||||
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = editData.name || '';
|
||||
haSelect.value = editData.ha_source_id || '';
|
||||
cssSelect.value = editData.color_strip_source_id || '';
|
||||
_ensureBrightnessWidget().setValue(editData.brightness ?? 1.0);
|
||||
_ensureUpdateRateWidget().setValue(editData.update_rate ?? 2.0);
|
||||
_ensureTransitionWidget().setValue(editData.transition ?? 0.5);
|
||||
_ensureColorToleranceWidget().setValue(editData.color_tolerance ?? 5);
|
||||
@@ -336,6 +351,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
||||
mappings.forEach((m: any) => addHALightMapping(m));
|
||||
} else {
|
||||
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = '';
|
||||
_ensureBrightnessWidget().setValue(1.0);
|
||||
_ensureUpdateRateWidget().setValue(2.0);
|
||||
_ensureTransitionWidget().setValue(0.5);
|
||||
_ensureColorToleranceWidget().setValue(5);
|
||||
@@ -375,23 +391,6 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
|
||||
// Brightness value source
|
||||
const bvsSelect = document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement;
|
||||
bvsSelect.innerHTML = `<option value="">${t('targets.brightness_vs.none')}</option>` +
|
||||
_cachedValueSources.map((vs: any) =>
|
||||
`<option value="${vs.id}" ${vs.id === bindableSourceId(editData?.brightness) ? 'selected' : ''}>${escapeHtml(vs.name)}</option>`
|
||||
).join('');
|
||||
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
|
||||
_brightnessVsEntitySelect = new EntitySelect({
|
||||
target: bvsSelect,
|
||||
getItems: () => _cachedValueSources.map((vs: any) => ({
|
||||
value: vs.id, label: vs.name, icon: getValueSourceIcon(vs.source_type), desc: vs.source_type,
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
allowNone: true,
|
||||
noneLabel: t('targets.brightness_vs.none'),
|
||||
});
|
||||
|
||||
// Tags
|
||||
if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; }
|
||||
_haLightTagsInput = new TagInput(document.getElementById('ha-light-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
@@ -430,13 +429,13 @@ export async function saveHALightEditor(): Promise<void> {
|
||||
// Collect mappings
|
||||
const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.entity_id);
|
||||
|
||||
const brightnessVsId = (document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement).value;
|
||||
const brightness = _brightnessWidget ? _brightnessWidget.getValue() : 1.0;
|
||||
|
||||
const payload: any = {
|
||||
name,
|
||||
ha_source_id: haSourceId,
|
||||
color_strip_source_id: cssSourceId,
|
||||
brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0,
|
||||
brightness,
|
||||
ha_light_mappings: mappings,
|
||||
update_rate: updateRate,
|
||||
transition,
|
||||
@@ -446,6 +445,8 @@ export async function saveHALightEditor(): Promise<void> {
|
||||
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
|
||||
};
|
||||
|
||||
payload.target_type = 'ha_light';
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (targetId) {
|
||||
@@ -454,7 +455,6 @@ export async function saveHALightEditor(): Promise<void> {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
payload.target_type = 'ha_light';
|
||||
response = await fetchWithAuth('/output-targets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
@@ -537,7 +537,7 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
|
||||
${cssName !== '—' ? `<span class="stream-card-prop${cssLink}" title="${t('targets.color_strip_source')}">${cssSource ? getColorStripIcon(cssSource.source_type) : _icon(P.palette)} ${cssName}</span>` : ''}
|
||||
<span class="stream-card-prop">${_icon(P.listChecks)} ${mappingCount} light${mappingCount !== 1 ? 's' : ''}</span>
|
||||
<span class="stream-card-prop">${_icon(P.clock)} ${target.update_rate ?? 2.0} Hz</span>
|
||||
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
|
||||
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : (bindableValue(target.brightness, 1.0) < 1.0 ? `<span class="stream-card-prop" title="${t('targets.brightness')}">${ICON_SUN} ${Math.round(bindableValue(target.brightness, 1.0) * 100)}%</span>` : '')}
|
||||
</div>
|
||||
${renderTagChips(target.tags || [])}
|
||||
${target.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(target.description)}</div>` : ''}
|
||||
|
||||
@@ -101,7 +101,7 @@ export function createPatternTemplateCard(pt: PatternTemplate) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null, opts?: { rects?: any[]; onSave?: (rects: any[]) => void }): Promise<void> {
|
||||
export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null, opts?: { rects?: any[]; pictureSourceId?: string; onSave?: (rects: any[]) => void }): Promise<void> {
|
||||
_inlineCallback = opts?.onSave || null;
|
||||
try {
|
||||
// Load sources for background capture
|
||||
@@ -199,6 +199,15 @@ export async function showPatternTemplateEditor(templateId: string | null = null
|
||||
|
||||
(document.getElementById('pattern-template-error') as HTMLElement).style.display = 'none';
|
||||
setTimeout(() => desktopFocus(document.getElementById('pattern-template-name')), 100);
|
||||
|
||||
// Auto-capture background from picture source (if provided)
|
||||
if (opts?.pictureSourceId) {
|
||||
// Pre-select the source in dropdown
|
||||
if (bgSelect) bgSelect.value = opts.pictureSourceId;
|
||||
if (_patternBgEntitySelect) _patternBgEntitySelect.refresh();
|
||||
// Capture after a short delay to let canvas resize
|
||||
setTimeout(() => capturePatternBackground(), 200);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open pattern template editor:', error);
|
||||
showToast(t('pattern.error.editor_open_failed'), 'error');
|
||||
|
||||
@@ -38,7 +38,7 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }:
|
||||
localStorage.setItem('activeTab', name);
|
||||
|
||||
// Update background tab indicator
|
||||
if (typeof window._updateTabIndicator === 'function') window._updateTabIndicator();
|
||||
if (typeof window._updateTabIndicator === 'function') window._updateTabIndicator(name);
|
||||
|
||||
// Restore scroll position for this tab
|
||||
requestAnimationFrame(() => window.scrollTo(0, _tabScrollPositions[name] || 0));
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
|
||||
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
|
||||
ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW,
|
||||
ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP,
|
||||
ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP,
|
||||
ICON_WARNING, ICON_PALETTE, ICON_WRENCH, ICON_TEMPLATE, ICON_TRASH,
|
||||
} from '../core/icons.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
@@ -35,7 +35,7 @@ import { createFpsSparkline } from '../core/chart-utils.ts';
|
||||
import { CardSection } from '../core/card-sections.ts';
|
||||
import { TreeNav } from '../core/tree-nav.ts';
|
||||
import { updateSubTabHash, updateTabBadge } from './tabs.ts';
|
||||
import type { OutputTarget } from '../types.ts';
|
||||
import type { OutputTarget, LedOutputTarget } from '../types.ts';
|
||||
import { bindableSourceId, bindableValue } from '../types.ts';
|
||||
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
|
||||
|
||||
@@ -144,6 +144,7 @@ function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
|
||||
// --- Editor state ---
|
||||
let _editorCssSources: any[] = []; // populated when editor opens
|
||||
let _targetTagsInput: TagInput | null = null;
|
||||
let _brightnessWidget: BindableScalarWidget | null = null;
|
||||
let _fpsWidget: BindableScalarWidget | null = null;
|
||||
let _thresholdWidget: BindableScalarWidget | null = null;
|
||||
|
||||
@@ -158,7 +159,7 @@ class TargetEditorModal extends Modal {
|
||||
device: (document.getElementById('target-editor-device') as HTMLSelectElement).value,
|
||||
protocol: (document.getElementById('target-editor-protocol') as HTMLSelectElement).value,
|
||||
css_source: (document.getElementById('target-editor-css-source') as HTMLSelectElement).value,
|
||||
brightness_vs: (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value,
|
||||
brightness: _brightnessWidget ? JSON.stringify(_brightnessWidget.getValue()) : '1.0',
|
||||
brightness_threshold: _thresholdWidget ? JSON.stringify(_thresholdWidget.getValue()) : '0',
|
||||
fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30',
|
||||
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
|
||||
@@ -258,7 +259,6 @@ function _updateBrightnessThresholdVisibility() {
|
||||
// ── EntitySelect instances for target editor ──
|
||||
let _deviceEntitySelect: EntitySelect | null = null;
|
||||
let _cssEntitySelect: EntitySelect | null = null;
|
||||
let _brightnessVsEntitySelect: EntitySelect | null = null;
|
||||
let _protocolIconSelect: IconSelect | null = null;
|
||||
|
||||
function _populateCssDropdown(selectedId = '') {
|
||||
@@ -268,15 +268,6 @@ function _populateCssDropdown(selectedId = '') {
|
||||
).join('');
|
||||
}
|
||||
|
||||
function _populateBrightnessVsDropdown(selectedId = '') {
|
||||
const select = document.getElementById('target-editor-brightness-vs') as HTMLSelectElement;
|
||||
let html = `<option value="">${t('targets.brightness_vs.none')}</option>`;
|
||||
_cachedValueSources.forEach(vs => {
|
||||
html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${escapeHtml(vs.name)}</option>`;
|
||||
});
|
||||
select.innerHTML = html;
|
||||
}
|
||||
|
||||
function _ensureTargetEntitySelects() {
|
||||
// Device
|
||||
if (_deviceEntitySelect) _deviceEntitySelect.destroy();
|
||||
@@ -304,20 +295,6 @@ function _ensureTargetEntitySelects() {
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
|
||||
// Brightness value source
|
||||
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
|
||||
_brightnessVsEntitySelect = new EntitySelect({
|
||||
target: document.getElementById('target-editor-brightness-vs') as HTMLSelectElement,
|
||||
getItems: () => _cachedValueSources.map(vs => ({
|
||||
value: vs.id,
|
||||
label: vs.name,
|
||||
icon: getValueSourceIcon(vs.source_type),
|
||||
desc: vs.source_type,
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
allowNone: true,
|
||||
noneLabel: t('targets.brightness_vs.none'),
|
||||
});
|
||||
}
|
||||
|
||||
const _pIcon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
@@ -333,6 +310,19 @@ function _ensureProtocolIconSelect() {
|
||||
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 });
|
||||
}
|
||||
|
||||
function _ensureBrightnessWidget(): BindableScalarWidget {
|
||||
if (!_brightnessWidget) {
|
||||
_brightnessWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('target-editor-brightness-container')!,
|
||||
min: 0, max: 1, step: 0.05, default: 1.0,
|
||||
idPrefix: 'target-editor-brightness',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(2),
|
||||
});
|
||||
}
|
||||
return _brightnessWidget;
|
||||
}
|
||||
|
||||
function _ensureFpsWidget(): BindableScalarWidget {
|
||||
if (!_fpsWidget) {
|
||||
_fpsWidget = new BindableScalarWidget({
|
||||
@@ -406,7 +396,7 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
|
||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
|
||||
|
||||
_populateCssDropdown(target.color_strip_source_id || '');
|
||||
_populateBrightnessVsDropdown(bindableSourceId(target.brightness));
|
||||
_ensureBrightnessWidget().setValue(target.brightness ?? 1.0);
|
||||
} else if (cloneData) {
|
||||
// Cloning — create mode but pre-filled from clone data
|
||||
_editorTags = cloneData.tags || [];
|
||||
@@ -424,7 +414,7 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
|
||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
|
||||
|
||||
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
||||
_populateBrightnessVsDropdown(bindableSourceId(cloneData.brightness));
|
||||
_ensureBrightnessWidget().setValue(cloneData.brightness ?? 1.0);
|
||||
} else {
|
||||
// Creating new target
|
||||
(document.getElementById('target-editor-id') as HTMLInputElement).value = '';
|
||||
@@ -440,7 +430,7 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
|
||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
|
||||
|
||||
_populateCssDropdown('');
|
||||
_populateBrightnessVsDropdown('');
|
||||
_ensureBrightnessWidget().setValue(1.0);
|
||||
}
|
||||
|
||||
// Entity palette selectors
|
||||
@@ -454,7 +444,6 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
|
||||
window._targetAutoName = _autoGenerateTargetName;
|
||||
deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateSpecificSettingsVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
|
||||
(document.getElementById('target-editor-css-source') as HTMLSelectElement).onchange = () => { _autoGenerateTargetName(); };
|
||||
(document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).onchange = () => { _updateBrightnessThresholdVisibility(); };
|
||||
if (!targetId && !cloneData) _autoGenerateTargetName();
|
||||
|
||||
// Show/hide conditional fields
|
||||
@@ -492,6 +481,7 @@ export async function closeTargetEditorModal() {
|
||||
|
||||
export function forceCloseTargetEditorModal() {
|
||||
if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; }
|
||||
if (_brightnessWidget) { _brightnessWidget.destroy(); _brightnessWidget = null; }
|
||||
if (_fpsWidget) { _fpsWidget.destroy(); _fpsWidget = null; }
|
||||
if (_thresholdWidget) { _thresholdWidget.destroy(); _thresholdWidget = null; }
|
||||
targetEditorModal.forceClose();
|
||||
@@ -511,7 +501,7 @@ export async function saveTargetEditor() {
|
||||
const fps = _fpsWidget ? _fpsWidget.getValue() : 30;
|
||||
const colorStripSourceId = (document.getElementById('target-editor-css-source') as HTMLSelectElement).value;
|
||||
|
||||
const brightnessVsId = (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value;
|
||||
const brightness = _brightnessWidget ? _brightnessWidget.getValue() : 1.0;
|
||||
const minBrightnessThreshold = _thresholdWidget ? _thresholdWidget.getValue() : 0;
|
||||
|
||||
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
|
||||
@@ -521,7 +511,7 @@ export async function saveTargetEditor() {
|
||||
name,
|
||||
device_id: deviceId,
|
||||
color_strip_source_id: colorStripSourceId,
|
||||
brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0,
|
||||
brightness,
|
||||
min_brightness_threshold: minBrightnessThreshold,
|
||||
fps,
|
||||
keepalive_interval: standbyInterval,
|
||||
@@ -530,6 +520,8 @@ export async function saveTargetEditor() {
|
||||
tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
|
||||
};
|
||||
|
||||
payload.target_type = 'led';
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (targetId) {
|
||||
@@ -538,7 +530,6 @@ export async function saveTargetEditor() {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
payload.target_type = 'led';
|
||||
response = await fetchWithAuth('/output-targets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
@@ -651,7 +642,7 @@ export async function loadTargetsTab() {
|
||||
|
||||
// Group by type
|
||||
const ledDevices = devicesWithState;
|
||||
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
|
||||
const ledTargets = targetsWithState.filter((t): t is LedOutputTarget & { state?: any; metrics?: any } => t.target_type === 'led' || (t.target_type as string) === 'wled');
|
||||
const haLightTargets = targetsWithState.filter(t => t.target_type === 'ha_light');
|
||||
|
||||
// Update tab badge with running target count
|
||||
@@ -969,7 +960,7 @@ function _patchTargetMetrics(target: any) {
|
||||
if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds);
|
||||
}
|
||||
|
||||
export function createTargetCard(target: OutputTarget & { state?: any; metrics?: any }, deviceMap: Record<string, any>, colorStripSourceMap: Record<string, any>, valueSourceMap: Record<string, any>) {
|
||||
export function createTargetCard(target: LedOutputTarget & { state?: any; metrics?: any }, deviceMap: Record<string, any>, colorStripSourceMap: Record<string, any>, valueSourceMap: Record<string, any>) {
|
||||
const state = target.state || {};
|
||||
|
||||
const isProcessing = state.processing || false;
|
||||
@@ -1015,7 +1006,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
|
||||
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${bindableValue(target.fps, 30)}</span>
|
||||
<span class="stream-card-prop" title="${t('targets.protocol')}">${_protocolBadge(device, target)}</span>
|
||||
<span class="stream-card-prop${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>
|
||||
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
|
||||
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : (bindableValue(target.brightness, 1.0) < 1.0 ? `<span class="stream-card-prop" title="${t('targets.brightness')}">${ICON_SUN} ${Math.round(bindableValue(target.brightness, 1.0) * 100)}%</span>` : '')}
|
||||
${bindableValue(target.min_brightness_threshold, 0) > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} <${bindableValue(target.min_brightness_threshold, 0)} → off</span>` : ''}
|
||||
</div>
|
||||
${renderTagChips(target.tags)}
|
||||
|
||||
@@ -10,22 +10,29 @@
|
||||
* This module manages the editor modal and API operations.
|
||||
*/
|
||||
|
||||
import { _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache } from '../core/state.ts';
|
||||
import {
|
||||
_cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache,
|
||||
_cachedHASources, _cachedColorStripSources, gradientsCache, GradientEntity,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import {
|
||||
getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon,
|
||||
getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon, getColorStripIcon, getHAEntityIcon,
|
||||
ICON_CLONE, ICON_EDIT, ICON_TEST,
|
||||
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK,
|
||||
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, ICON_TRASH,
|
||||
ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS,
|
||||
} from '../core/icons.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
|
||||
import type { IconSelectItem } from '../core/icon-select.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
import { hexToRgbArray, rgbArrayToHex } from './css-gradient-editor.ts';
|
||||
import type { ValueSource } from '../types.ts';
|
||||
|
||||
export { getValueSourceIcon };
|
||||
@@ -33,6 +40,13 @@ export { getValueSourceIcon };
|
||||
// ── EntitySelect instances for value source editor ──
|
||||
let _vsAudioSourceEntitySelect: EntitySelect | null = null;
|
||||
let _vsPictureSourceEntitySelect: EntitySelect | null = null;
|
||||
let _vsHASourceEntitySelect: EntitySelect | null = null;
|
||||
let _vsHAEntityEntitySelect: EntitySelect | null = null;
|
||||
let _vsHAEntities: any[] = [];
|
||||
let _vsGradientInputEntitySelect: EntitySelect | null = null;
|
||||
let _vsGradientEntitySelect: EntitySelect | null = null;
|
||||
let _vsCSSSourceEntitySelect: EntitySelect | null = null;
|
||||
let _vsGradientEasingIconSelect: IconSelect | null = null;
|
||||
let _vsTagsInput: TagInput | null = null;
|
||||
|
||||
class ValueSourceModal extends Modal {
|
||||
@@ -40,6 +54,13 @@ class ValueSourceModal extends Modal {
|
||||
|
||||
onForceClose() {
|
||||
if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; }
|
||||
if (_vsColorEasingIconSelect) { _vsColorEasingIconSelect.destroy(); _vsColorEasingIconSelect = null; }
|
||||
if (_vsHASourceEntitySelect) { _vsHASourceEntitySelect.destroy(); _vsHASourceEntitySelect = null; }
|
||||
if (_vsHAEntityEntitySelect) { _vsHAEntityEntitySelect.destroy(); _vsHAEntityEntitySelect = null; }
|
||||
if (_vsGradientInputEntitySelect) { _vsGradientInputEntitySelect.destroy(); _vsGradientInputEntitySelect = null; }
|
||||
if (_vsGradientEntitySelect) { _vsGradientEntitySelect.destroy(); _vsGradientEntitySelect = null; }
|
||||
if (_vsCSSSourceEntitySelect) { _vsCSSSourceEntitySelect.destroy(); _vsCSSSourceEntitySelect = null; }
|
||||
if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
@@ -68,6 +89,11 @@ class ValueSourceModal extends Modal {
|
||||
daylightSpeed: (document.getElementById('value-source-daylight-speed') as HTMLInputElement).value,
|
||||
daylightRealTime: (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked,
|
||||
daylightLatitude: (document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value,
|
||||
staticColor: (document.getElementById('value-source-static-color') as HTMLInputElement).value,
|
||||
animatedColors: JSON.stringify(_animatedColors),
|
||||
animatedColorSpeed: (document.getElementById('value-source-animated-color-speed') as HTMLInputElement).value,
|
||||
animatedColorEasing: (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value,
|
||||
colorSchedule: JSON.stringify(_colorSchedulePoints),
|
||||
tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
@@ -101,10 +127,20 @@ function _autoGenerateVSName() {
|
||||
|
||||
/* ── Icon-grid type selector ──────────────────────────────────── */
|
||||
|
||||
const VS_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight'];
|
||||
const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity'];
|
||||
const VS_COLOR_TYPE_KEYS = ['static_color', 'animated_color', 'adaptive_time_color', 'gradient_map', 'css_extract'];
|
||||
const VS_TYPE_KEYS = [...VS_FLOAT_TYPE_KEYS, ...VS_COLOR_TYPE_KEYS];
|
||||
|
||||
function _buildVSTypeItems() {
|
||||
return VS_TYPE_KEYS.map(key => ({
|
||||
let _vsTypeFilter: 'all' | 'float' | 'color' = 'all';
|
||||
|
||||
function _getFilteredTypeKeys(): string[] {
|
||||
if (_vsTypeFilter === 'float') return VS_FLOAT_TYPE_KEYS;
|
||||
if (_vsTypeFilter === 'color') return VS_COLOR_TYPE_KEYS;
|
||||
return VS_TYPE_KEYS;
|
||||
}
|
||||
|
||||
function _buildVSTypeItems(): IconSelectItem[] {
|
||||
return _getFilteredTypeKeys().map(key => ({
|
||||
value: key,
|
||||
icon: getValueSourceIcon(key),
|
||||
label: t(`value_source.type.${key}`),
|
||||
@@ -112,8 +148,11 @@ function _buildVSTypeItems() {
|
||||
}));
|
||||
}
|
||||
|
||||
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
let _vsTypeIconSelect: IconSelect | null = null;
|
||||
let _waveformIconSelect: IconSelect | null = null;
|
||||
let _vsColorEasingIconSelect: IconSelect | null = null;
|
||||
|
||||
const _WAVEFORM_SVG = {
|
||||
sine: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 12 Q15 -4 30 12 Q45 28 60 12"/></svg>',
|
||||
@@ -135,6 +174,17 @@ function _ensureWaveformIconSelect() {
|
||||
_waveformIconSelect = new IconSelect({ target: sel, items, columns: 4 } as any);
|
||||
}
|
||||
|
||||
function _ensureColorEasingIconSelect() {
|
||||
const sel = document.getElementById('value-source-animated-color-easing') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const items = [
|
||||
{ value: 'linear', icon: _icon(P.activity), label: t('value_source.animated_color.easing.linear'), desc: t('value_source.animated_color.easing.linear.desc') },
|
||||
{ value: 'step', icon: _icon(P.layoutDashboard), label: t('value_source.animated_color.easing.step'), desc: t('value_source.animated_color.easing.step.desc') },
|
||||
];
|
||||
if (_vsColorEasingIconSelect) { _vsColorEasingIconSelect.updateItems(items); return; }
|
||||
_vsColorEasingIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
|
||||
}
|
||||
|
||||
/* ── Waveform canvas preview ──────────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -251,10 +301,20 @@ function _ensureVSTypeIconSelect() {
|
||||
export async function showValueSourceModal(editData: any, presetType: any = null) {
|
||||
// When creating new: show type picker first, then re-enter with presetType
|
||||
if (!editData && !presetType) {
|
||||
_vsTypeFilter = 'all';
|
||||
showTypePicker({
|
||||
title: t('value_source.select_type'),
|
||||
items: _buildVSTypeItems(),
|
||||
onPick: (type) => showValueSourceModal(null, type),
|
||||
filterTabs: [
|
||||
{ key: 'all', label: t('value_source.filter.all') },
|
||||
{ key: 'float', label: t('value_source.filter.float') },
|
||||
{ key: 'color', label: t('value_source.filter.color') },
|
||||
],
|
||||
onFilterChange: (key) => {
|
||||
_vsTypeFilter = key as any;
|
||||
return _buildVSTypeItems();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -322,6 +382,38 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
||||
_syncDaylightVSSpeedVisibility();
|
||||
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
|
||||
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
|
||||
} else if (editData.source_type === 'static_color') {
|
||||
const rgb = editData.color || [255, 255, 255];
|
||||
(document.getElementById('value-source-static-color') as HTMLInputElement).value = rgbArrayToHex(rgb);
|
||||
} else if (editData.source_type === 'animated_color') {
|
||||
_animatedColors = (editData.colors || [[255, 0, 0], [0, 255, 0], [0, 0, 255]]).map((c: number[]) => rgbArrayToHex(c));
|
||||
_renderAnimatedColorList();
|
||||
_setSlider('value-source-animated-color-speed', editData.speed ?? 10.0);
|
||||
(document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value = editData.easing || 'linear';
|
||||
_ensureColorEasingIconSelect();
|
||||
} else if (editData.source_type === 'adaptive_time_color') {
|
||||
_colorSchedulePoints = (editData.schedule || []).map((p: any) => ({
|
||||
time: p.time,
|
||||
color: rgbArrayToHex(p.color || [255, 255, 255]),
|
||||
}));
|
||||
_renderColorScheduleList();
|
||||
} else if (editData.source_type === 'ha_entity') {
|
||||
_populateHASourceDropdown(editData.ha_source_id || '');
|
||||
await _fetchVSHAEntities(editData.ha_source_id || '');
|
||||
_populateHAEntityDropdown(editData.entity_id || '');
|
||||
(document.getElementById('value-source-attribute') as HTMLInputElement).value = editData.attribute || '';
|
||||
(document.getElementById('value-source-min-ha-value') as HTMLInputElement).value = String(editData.min_ha_value ?? 0);
|
||||
(document.getElementById('value-source-max-ha-value') as HTMLInputElement).value = String(editData.max_ha_value ?? 100);
|
||||
_setSlider('value-source-ha-smoothing', editData.smoothing ?? 0);
|
||||
} else if (editData.source_type === 'gradient_map') {
|
||||
_populateGradientInputDropdown(editData.value_source_id || '');
|
||||
_populateGradientEntityDropdown(editData.gradient_id || '');
|
||||
(document.getElementById('value-source-gradient-easing') as HTMLSelectElement).value = editData.easing || 'linear';
|
||||
_ensureGradientEasingIconSelect();
|
||||
} else if (editData.source_type === 'css_extract') {
|
||||
_populateCSSSourceDropdown(editData.color_strip_source_id || '');
|
||||
(document.getElementById('value-source-led-start') as HTMLInputElement).value = String(editData.led_start ?? 0);
|
||||
(document.getElementById('value-source-led-end') as HTMLInputElement).value = String(editData.led_end ?? -1);
|
||||
}
|
||||
} else {
|
||||
(document.getElementById('value-source-name') as HTMLInputElement).value = '';
|
||||
@@ -355,6 +447,25 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
||||
(document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked = false;
|
||||
_setSlider('value-source-daylight-latitude', 50);
|
||||
_syncDaylightVSSpeedVisibility();
|
||||
// Color type defaults
|
||||
(document.getElementById('value-source-static-color') as HTMLInputElement).value = '#ffffff';
|
||||
_animatedColors = ['#ff0000', '#00ff00', '#0000ff'];
|
||||
_renderAnimatedColorList();
|
||||
_setSlider('value-source-animated-color-speed', 10.0);
|
||||
(document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value = 'linear';
|
||||
_colorSchedulePoints = [];
|
||||
_renderColorScheduleList();
|
||||
// HA entity defaults
|
||||
(document.getElementById('value-source-entity-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('value-source-attribute') as HTMLInputElement).value = '';
|
||||
(document.getElementById('value-source-min-ha-value') as HTMLInputElement).value = '0';
|
||||
(document.getElementById('value-source-max-ha-value') as HTMLInputElement).value = '100';
|
||||
_setSlider('value-source-ha-smoothing', 0);
|
||||
// Gradient map defaults
|
||||
(document.getElementById('value-source-gradient-easing') as HTMLSelectElement).value = 'linear';
|
||||
// CSS extract defaults
|
||||
(document.getElementById('value-source-led-start') as HTMLInputElement).value = '0';
|
||||
(document.getElementById('value-source-led-end') as HTMLInputElement).value = '-1';
|
||||
_autoGenerateVSName();
|
||||
}
|
||||
|
||||
@@ -387,6 +498,13 @@ export function onValueSourceTypeChange() {
|
||||
(document.getElementById('value-source-adaptive-time-section') as HTMLElement).style.display = type === 'adaptive_time' ? '' : 'none';
|
||||
(document.getElementById('value-source-adaptive-scene-section') as HTMLElement).style.display = type === 'adaptive_scene' ? '' : 'none';
|
||||
(document.getElementById('value-source-daylight-section') as HTMLElement).style.display = type === 'daylight' ? '' : 'none';
|
||||
(document.getElementById('value-source-static-color-section') as HTMLElement).style.display = type === 'static_color' ? '' : 'none';
|
||||
(document.getElementById('value-source-animated-color-section') as HTMLElement).style.display = type === 'animated_color' ? '' : 'none';
|
||||
if (type === 'animated_color') _ensureColorEasingIconSelect();
|
||||
(document.getElementById('value-source-adaptive-time-color-section') as HTMLElement).style.display = type === 'adaptive_time_color' ? '' : 'none';
|
||||
(document.getElementById('value-source-ha-entity-section') as HTMLElement).style.display = type === 'ha_entity' ? '' : 'none';
|
||||
(document.getElementById('value-source-gradient-map-section') as HTMLElement).style.display = type === 'gradient_map' ? '' : 'none';
|
||||
(document.getElementById('value-source-css-extract-section') as HTMLElement).style.display = type === 'css_extract' ? '' : 'none';
|
||||
(document.getElementById('value-source-adaptive-range-section') as HTMLElement).style.display =
|
||||
(type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none';
|
||||
|
||||
@@ -403,6 +521,28 @@ export function onValueSourceTypeChange() {
|
||||
_populatePictureSourceDropdown('');
|
||||
}
|
||||
|
||||
// Populate HA source dropdown when switching to ha_entity type
|
||||
if (type === 'ha_entity') {
|
||||
_populateHASourceDropdown('');
|
||||
// Auto-fetch entities for the pre-selected HA source
|
||||
const haSelect = document.getElementById('value-source-ha-source') as HTMLSelectElement;
|
||||
if (haSelect?.value) {
|
||||
_fetchVSHAEntities(haSelect.value).then(() => _populateHAEntityDropdown(''));
|
||||
}
|
||||
}
|
||||
|
||||
// Populate gradient input and gradient entity dropdowns when switching to gradient_map type
|
||||
if (type === 'gradient_map') {
|
||||
_populateGradientInputDropdown('');
|
||||
_populateGradientEntityDropdown('');
|
||||
_ensureGradientEasingIconSelect();
|
||||
}
|
||||
|
||||
// Populate CSS source dropdown when switching to css_extract type
|
||||
if (type === 'css_extract') {
|
||||
_populateCSSSourceDropdown('');
|
||||
}
|
||||
|
||||
_autoGenerateVSName();
|
||||
}
|
||||
|
||||
@@ -471,6 +611,55 @@ export async function saveValueSource() {
|
||||
payload.latitude = parseFloat((document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value);
|
||||
payload.min_value = parseFloat((document.getElementById('value-source-adaptive-min-value') as HTMLInputElement).value);
|
||||
payload.max_value = parseFloat((document.getElementById('value-source-adaptive-max-value') as HTMLInputElement).value);
|
||||
} else if (sourceType === 'static_color') {
|
||||
payload.color = hexToRgbArray((document.getElementById('value-source-static-color') as HTMLInputElement).value);
|
||||
} else if (sourceType === 'animated_color') {
|
||||
payload.colors = _getAnimatedColorsPayload();
|
||||
payload.speed = parseFloat((document.getElementById('value-source-animated-color-speed') as HTMLInputElement).value);
|
||||
payload.easing = (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value;
|
||||
} else if (sourceType === 'adaptive_time_color') {
|
||||
payload.schedule = _getColorSchedulePayload();
|
||||
} else if (sourceType === 'ha_entity') {
|
||||
payload.ha_source_id = (document.getElementById('value-source-ha-source') as HTMLSelectElement).value;
|
||||
payload.entity_id = (document.getElementById('value-source-entity-id') as HTMLSelectElement).value;
|
||||
payload.attribute = (document.getElementById('value-source-attribute') as HTMLInputElement).value.trim();
|
||||
payload.min_ha_value = parseFloat((document.getElementById('value-source-min-ha-value') as HTMLInputElement).value);
|
||||
payload.max_ha_value = parseFloat((document.getElementById('value-source-max-ha-value') as HTMLInputElement).value);
|
||||
payload.smoothing = parseFloat((document.getElementById('value-source-ha-smoothing') as HTMLInputElement).value);
|
||||
if (!payload.ha_source_id) {
|
||||
errorEl.textContent = t('value_source.ha_source') + ' required';
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
if (!payload.entity_id) {
|
||||
errorEl.textContent = t('value_source.entity_id') + ' required';
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
} else if (sourceType === 'gradient_map') {
|
||||
payload.value_source_id = (document.getElementById('value-source-gradient-input') as HTMLSelectElement).value;
|
||||
payload.gradient_id = (document.getElementById('value-source-gradient-id') as HTMLSelectElement).value;
|
||||
payload.easing = (document.getElementById('value-source-gradient-easing') as HTMLSelectElement).value;
|
||||
if (!payload.value_source_id) {
|
||||
errorEl.textContent = t('value_source.input_source') + ' required';
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
if (!payload.gradient_id) {
|
||||
errorEl.textContent = t('value_source.gradient_stops') + ' required';
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
} else if (sourceType === 'css_extract') {
|
||||
payload.color_strip_source_id = (document.getElementById('value-source-css-source') as HTMLSelectElement).value;
|
||||
payload.led_start = parseInt((document.getElementById('value-source-led-start') as HTMLInputElement).value) || 0;
|
||||
payload.led_end = parseInt((document.getElementById('value-source-led-end') as HTMLInputElement).value);
|
||||
if (isNaN(payload.led_end)) payload.led_end = -1;
|
||||
if (!payload.color_strip_source_id) {
|
||||
errorEl.textContent = t('value_source.css_source') + ' required';
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -799,6 +988,54 @@ export function createValueSourceCard(src: ValueSource) {
|
||||
${psBadge}
|
||||
<span class="stream-card-prop">${ICON_REFRESH} ${src.scene_behavior || 'complement'}</span>
|
||||
`;
|
||||
} else if (src.source_type === 'static_color') {
|
||||
const rgb = (src as any).color || [255, 255, 255];
|
||||
const hex = rgbArrayToHex(rgb);
|
||||
propsHtml = `<span class="stream-card-prop"><span style="display:inline-block;width:12px;height:12px;background:${hex};border:1px solid #888;border-radius:2px;vertical-align:middle"></span> ${hex}</span>`;
|
||||
} else if (src.source_type === 'animated_color') {
|
||||
const colors = (src as any).colors || [];
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">${ICON_ACTIVITY} ${colors.length} ${t('value_source.animated_color.color_count')}</span>
|
||||
<span class="stream-card-prop">${ICON_TIMER} ${(src as any).speed ?? 10} cpm</span>
|
||||
`;
|
||||
} else if (src.source_type === 'adaptive_time_color') {
|
||||
const pts = ((src as any).schedule || []).length;
|
||||
propsHtml = `<span class="stream-card-prop">${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}</span>`;
|
||||
} else if (src.source_type === 'ha_entity') {
|
||||
const haSrc = _cachedHASources.find(h => h.id === (src as any).ha_source_id);
|
||||
const haName = haSrc ? haSrc.name : ((src as any).ha_source_id || '-');
|
||||
const entityId = (src as any).entity_id || '';
|
||||
const attr = (src as any).attribute;
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">${ICON_HOME} ${escapeHtml(haName)}</span>
|
||||
<span class="stream-card-prop">${ICON_LINK} ${escapeHtml(entityId)}${attr ? '.' + escapeHtml(attr) : ''}</span>
|
||||
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${(src as any).min_ha_value ?? 0}\u2013${(src as any).max_ha_value ?? 100}</span>
|
||||
`;
|
||||
} else if (src.source_type === 'gradient_map') {
|
||||
const inputVs = _cachedValueSources.find(v => v.id === (src as any).value_source_id);
|
||||
const inputName = inputVs ? inputVs.name : ((src as any).value_source_id || '-');
|
||||
const gradients = gradientsCache.data || [];
|
||||
const grad = gradients.find(g => g.id === (src as any).gradient_id);
|
||||
const gradName = grad ? grad.name : ((src as any).gradient_id || '-');
|
||||
const stops = grad?.stops || [];
|
||||
const gradientCss = stops.length >= 2
|
||||
? `linear-gradient(to right, ${stops.map((s: any) => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ')})`
|
||||
: '#333';
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">${ICON_LINK} ${escapeHtml(inputName)}</span>
|
||||
<span class="stream-card-prop">${ICON_RAINBOW} ${escapeHtml(gradName)}</span>
|
||||
<div style="height:8px;border-radius:4px;margin:4px 0;background:${gradientCss};"></div>
|
||||
`;
|
||||
} else if (src.source_type === 'css_extract') {
|
||||
const cssSrc = _cachedColorStripSources.find(c => c.id === (src as any).color_strip_source_id);
|
||||
const cssName = cssSrc ? cssSrc.name : ((src as any).color_strip_source_id || '-');
|
||||
const ledStart = (src as any).led_start ?? 0;
|
||||
const ledEnd = (src as any).led_end ?? -1;
|
||||
const rangeLabel = ledEnd < 0 ? `${ledStart}\u2013all` : `${ledStart}\u2013${ledEnd}`;
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">${ICON_DROPLETS} ${escapeHtml(cssName)}</span>
|
||||
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} LED ${rangeLabel}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return wrapCard({
|
||||
@@ -973,3 +1210,236 @@ function _populateScheduleUI(schedule: any) {
|
||||
schedule.forEach(p => addSchedulePoint(p.time, p.value));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Animated Color helpers ──────────────────────────────────
|
||||
|
||||
let _animatedColors: string[] = ['#ff0000', '#00ff00', '#0000ff'];
|
||||
|
||||
export function addAnimatedColor(color: string = '#ffffff') {
|
||||
_animatedColors = [..._animatedColors, color];
|
||||
_renderAnimatedColorList();
|
||||
}
|
||||
|
||||
export function removeAnimatedColor(idx: number) {
|
||||
_animatedColors = _animatedColors.filter((_, i) => i !== idx);
|
||||
_renderAnimatedColorList();
|
||||
}
|
||||
|
||||
function _renderAnimatedColorList() {
|
||||
const list = document.getElementById('value-source-animated-color-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = _animatedColors.map((c, i) => `
|
||||
<div class="schedule-row" style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
||||
<input type="color" class="animated-color-input" value="${c}" data-idx="${i}"
|
||||
onchange="_animatedColors[${i}] = this.value">
|
||||
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeAnimatedColor(${i})">${ICON_TRASH}</button>
|
||||
</div>
|
||||
`).join('');
|
||||
// Wire up color inputs to update state immutably
|
||||
list.querySelectorAll('.animated-color-input').forEach((el) => {
|
||||
const input = el as HTMLInputElement;
|
||||
const idx = parseInt(input.dataset.idx || '0', 10);
|
||||
input.addEventListener('input', () => {
|
||||
_animatedColors = _animatedColors.map((c, i) => i === idx ? input.value : c);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _getAnimatedColorsPayload(): number[][] {
|
||||
return _animatedColors.map(c => hexToRgbArray(c));
|
||||
}
|
||||
|
||||
// ── Color Schedule helpers ──────────────────────────────────
|
||||
|
||||
let _colorSchedulePoints: { time: string; color: string }[] = [];
|
||||
|
||||
export function addColorSchedulePoint(time: string = '12:00', color: string = '#ffffff') {
|
||||
_colorSchedulePoints = [..._colorSchedulePoints, { time, color }];
|
||||
_renderColorScheduleList();
|
||||
}
|
||||
|
||||
export function removeColorSchedulePoint(idx: number) {
|
||||
_colorSchedulePoints = _colorSchedulePoints.filter((_, i) => i !== idx);
|
||||
_renderColorScheduleList();
|
||||
}
|
||||
|
||||
function _renderColorScheduleList() {
|
||||
const list = document.getElementById('value-source-color-schedule-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = _colorSchedulePoints.map((p, i) => `
|
||||
<div class="schedule-row" style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
||||
<input type="time" class="color-schedule-time" value="${p.time}" data-idx="${i}">
|
||||
<input type="color" class="color-schedule-color" value="${p.color}" data-idx="${i}">
|
||||
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeColorSchedulePoint(${i})">${ICON_TRASH}</button>
|
||||
</div>
|
||||
`).join('');
|
||||
list.querySelectorAll('.color-schedule-time').forEach((el) => {
|
||||
const input = el as HTMLInputElement;
|
||||
const idx = parseInt(input.dataset.idx || '0', 10);
|
||||
input.addEventListener('input', () => {
|
||||
_colorSchedulePoints = _colorSchedulePoints.map((p, i) => i === idx ? { ...p, time: input.value } : p);
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.color-schedule-color').forEach((el) => {
|
||||
const input = el as HTMLInputElement;
|
||||
const idx = parseInt(input.dataset.idx || '0', 10);
|
||||
input.addEventListener('input', () => {
|
||||
_colorSchedulePoints = _colorSchedulePoints.map((p, i) => i === idx ? { ...p, color: input.value } : p);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _getColorSchedulePayload(): { time: string; color: number[] }[] {
|
||||
return _colorSchedulePoints.map(p => ({ time: p.time, color: hexToRgbArray(p.color) }));
|
||||
}
|
||||
|
||||
// ── HA Entity helpers ──────────────────────────────────────
|
||||
|
||||
function _populateHASourceDropdown(selectedId: string) {
|
||||
const select = document.getElementById('value-source-ha-source') as HTMLSelectElement;
|
||||
if (!select) return;
|
||||
select.innerHTML = _cachedHASources.map((s: any) =>
|
||||
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
|
||||
if (_vsHASourceEntitySelect) _vsHASourceEntitySelect.destroy();
|
||||
if (_cachedHASources.length > 0) {
|
||||
_vsHASourceEntitySelect = new EntitySelect({
|
||||
target: select,
|
||||
getItems: () => _cachedHASources.map(s => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
icon: ICON_HOME,
|
||||
desc: s.host || '',
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
onChange: (value) => {
|
||||
_fetchVSHAEntities(value).then(() => _populateHAEntityDropdown(''));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function _fetchVSHAEntities(haSourceId: string): Promise<void> {
|
||||
if (!haSourceId) { _vsHAEntities = []; return; }
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
|
||||
if (!resp.ok) { _vsHAEntities = []; return; }
|
||||
const data = await resp.json();
|
||||
_vsHAEntities = data.entities || [];
|
||||
} catch {
|
||||
_vsHAEntities = [];
|
||||
}
|
||||
}
|
||||
|
||||
function _populateHAEntityDropdown(selectedId: string) {
|
||||
const select = document.getElementById('value-source-entity-id') as HTMLSelectElement;
|
||||
if (!select) return;
|
||||
select.innerHTML = _vsHAEntities.map((e: any) =>
|
||||
`<option value="${e.entity_id}"${e.entity_id === selectedId ? ' selected' : ''}>${escapeHtml(e.friendly_name || e.entity_id)}</option>`
|
||||
).join('');
|
||||
|
||||
if (_vsHAEntityEntitySelect) _vsHAEntityEntitySelect.destroy();
|
||||
if (_vsHAEntities.length > 0) {
|
||||
_vsHAEntityEntitySelect = new EntitySelect({
|
||||
target: select,
|
||||
getItems: () => _vsHAEntities.map((e: any) => ({
|
||||
value: e.entity_id,
|
||||
label: e.friendly_name || e.entity_id,
|
||||
icon: getHAEntityIcon(e),
|
||||
desc: e.entity_id,
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gradient Map helpers ───────────────────────────────────
|
||||
|
||||
function _populateGradientInputDropdown(selectedId: string) {
|
||||
const select = document.getElementById('value-source-gradient-input') as HTMLSelectElement;
|
||||
if (!select) return;
|
||||
const floatSources = _cachedValueSources.filter(v => v.return_type === 'float');
|
||||
select.innerHTML = floatSources.map((s: any) =>
|
||||
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
|
||||
if (_vsGradientInputEntitySelect) _vsGradientInputEntitySelect.destroy();
|
||||
if (floatSources.length > 0) {
|
||||
_vsGradientInputEntitySelect = new EntitySelect({
|
||||
target: select,
|
||||
getItems: () => floatSources.map(s => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
icon: getValueSourceIcon(s.source_type),
|
||||
desc: s.source_type,
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _ensureGradientEasingIconSelect() {
|
||||
const sel = document.getElementById('value-source-gradient-easing') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const items = [
|
||||
{ value: 'linear', icon: _icon(P.activity), label: 'Linear', desc: 'Smooth blend' },
|
||||
{ value: 'step', icon: _icon(P.layoutDashboard), label: 'Step', desc: 'Hard edges' },
|
||||
];
|
||||
if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.updateItems(items); return; }
|
||||
_vsGradientEasingIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
|
||||
}
|
||||
|
||||
function _populateGradientEntityDropdown(selectedId: string) {
|
||||
const select = document.getElementById('value-source-gradient-id') as HTMLSelectElement;
|
||||
if (!select) return;
|
||||
const gradients = gradientsCache.data || [];
|
||||
select.innerHTML = gradients.map((g: GradientEntity) =>
|
||||
`<option value="${g.id}"${g.id === selectedId ? ' selected' : ''}>${escapeHtml(g.name)}</option>`
|
||||
).join('');
|
||||
|
||||
if (_vsGradientEntitySelect) _vsGradientEntitySelect.destroy();
|
||||
if (gradients.length > 0) {
|
||||
_vsGradientEntitySelect = new EntitySelect({
|
||||
target: select,
|
||||
getItems: () => (gradientsCache.data || []).map((g: GradientEntity) => {
|
||||
const stops = g.stops || [];
|
||||
const stripHtml = stops.length >= 2
|
||||
? `<span style="display:inline-block;width:80px;height:16px;border-radius:3px;background:linear-gradient(to right,${stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ')});flex-shrink:0"></span>`
|
||||
: ICON_RAINBOW;
|
||||
return {
|
||||
value: g.id,
|
||||
label: g.name,
|
||||
icon: stripHtml,
|
||||
desc: `${stops.length} stops`,
|
||||
};
|
||||
}),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── CSS Extract helpers ────────────────────────────────────
|
||||
|
||||
function _populateCSSSourceDropdown(selectedId: string) {
|
||||
const select = document.getElementById('value-source-css-source') as HTMLSelectElement;
|
||||
if (!select) return;
|
||||
select.innerHTML = _cachedColorStripSources.map((s: any) =>
|
||||
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
|
||||
if (_vsCSSSourceEntitySelect) _vsCSSSourceEntitySelect.destroy();
|
||||
if (_cachedColorStripSources.length > 0) {
|
||||
_vsCSSSourceEntitySelect = new EntitySelect({
|
||||
target: select,
|
||||
getItems: () => _cachedColorStripSources.map(s => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
icon: getColorStripIcon(s.source_type),
|
||||
desc: s.source_type,
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -23,7 +23,7 @@ interface Window {
|
||||
// ─── Visual effects (called from inline <script>) ───
|
||||
_updateBgAnimAccent: (accent: string) => void;
|
||||
_updateBgAnimTheme: (dark: boolean) => void;
|
||||
_updateTabIndicator: () => void;
|
||||
_updateTabIndicator: (tabName?: string) => void;
|
||||
|
||||
// ─── Core / UI ───
|
||||
toggleHint: (...args: any[]) => any;
|
||||
@@ -286,6 +286,10 @@ startTargetOverlay: (...args: any[]) => any;
|
||||
onValueSourceTypeChange: (...args: any[]) => any;
|
||||
onDaylightVSRealTimeChange: (...args: any[]) => any;
|
||||
addSchedulePoint: (...args: any[]) => any;
|
||||
addAnimatedColor: (...args: any[]) => any;
|
||||
removeAnimatedColor: (...args: any[]) => any;
|
||||
addColorSchedulePoint: (...args: any[]) => any;
|
||||
removeColorSchedulePoint: (...args: any[]) => any;
|
||||
testValueSource: (...args: any[]) => any;
|
||||
closeTestValueSourceModal: (...args: any[]) => any;
|
||||
|
||||
|
||||
@@ -24,6 +24,25 @@ export function bindableSourceId(b: BindableFloat | undefined): string {
|
||||
return b.source_id ?? '';
|
||||
}
|
||||
|
||||
// ── Bindable Color ──────────────────────────────────────────
|
||||
// An RGB color that is either static ([R,G,B] array) or bound to a color value source.
|
||||
|
||||
export type BindableColor = number[] | { color: number[]; source_id: string };
|
||||
|
||||
/** Extract the static [R,G,B] from a BindableColor. */
|
||||
export function bindableColor(b: BindableColor | undefined, fallback: number[]): number[] {
|
||||
if (b === undefined || b === null) return fallback;
|
||||
if (Array.isArray(b)) return b;
|
||||
return b.color ?? fallback;
|
||||
}
|
||||
|
||||
/** Extract the source_id from a BindableColor (empty string = not bound). */
|
||||
export function bindableColorSourceId(b: BindableColor | undefined): string {
|
||||
if (b === undefined || b === null) return '';
|
||||
if (Array.isArray(b)) return '';
|
||||
return b.source_id ?? '';
|
||||
}
|
||||
|
||||
// ── Device ────────────────────────────────────────────────────
|
||||
|
||||
export type DeviceType =
|
||||
@@ -66,7 +85,14 @@ export interface Device {
|
||||
|
||||
export type TargetType = 'led' | 'ha_light';
|
||||
|
||||
export interface OutputTarget {
|
||||
export interface HALightMapping {
|
||||
entity_id: string;
|
||||
led_start: number;
|
||||
led_end: number;
|
||||
brightness_scale: BindableFloat;
|
||||
}
|
||||
|
||||
interface OutputTargetBase {
|
||||
id: string;
|
||||
name: string;
|
||||
target_type: TargetType;
|
||||
@@ -74,32 +100,34 @@ export interface OutputTarget {
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// LED target fields
|
||||
device_id?: string;
|
||||
color_strip_source_id?: string;
|
||||
export interface LedOutputTarget extends OutputTargetBase {
|
||||
target_type: 'led';
|
||||
device_id: string;
|
||||
color_strip_source_id: string;
|
||||
brightness?: BindableFloat;
|
||||
fps?: BindableFloat;
|
||||
keepalive_interval?: number;
|
||||
state_check_interval?: number;
|
||||
keepalive_interval: number;
|
||||
state_check_interval: number;
|
||||
min_brightness_threshold?: BindableFloat;
|
||||
adaptive_fps?: boolean;
|
||||
protocol?: string;
|
||||
adaptive_fps: boolean;
|
||||
protocol: string;
|
||||
}
|
||||
|
||||
// HA light target fields
|
||||
ha_source_id?: string;
|
||||
export interface HALightOutputTarget extends OutputTargetBase {
|
||||
target_type: 'ha_light';
|
||||
ha_source_id: string;
|
||||
color_strip_source_id: string;
|
||||
brightness?: BindableFloat;
|
||||
ha_light_mappings?: HALightMapping[];
|
||||
update_rate?: BindableFloat;
|
||||
transition?: BindableFloat;
|
||||
color_tolerance?: BindableFloat;
|
||||
min_brightness_threshold?: BindableFloat;
|
||||
}
|
||||
|
||||
export interface HALightMapping {
|
||||
entity_id: string;
|
||||
led_start: number;
|
||||
led_end: number;
|
||||
brightness_scale: BindableFloat;
|
||||
}
|
||||
export type OutputTarget = LedOutputTarget | HALightOutputTarget;
|
||||
|
||||
// ── Color Strip Source ────────────────────────────────────────
|
||||
|
||||
@@ -188,8 +216,8 @@ export interface ColorStripSource {
|
||||
interpolation_mode?: string;
|
||||
calibration?: Calibration;
|
||||
|
||||
// Static
|
||||
color?: number[];
|
||||
// Static / Effect / Candlelight
|
||||
color?: BindableColor;
|
||||
|
||||
// Gradient
|
||||
stops?: ColorStop[];
|
||||
@@ -214,21 +242,21 @@ export interface ColorStripSource {
|
||||
visualization_mode?: string;
|
||||
audio_source_id?: string;
|
||||
sensitivity?: BindableFloat;
|
||||
color_peak?: number[];
|
||||
color_peak?: BindableColor;
|
||||
|
||||
// Animation
|
||||
animation?: AnimationConfig;
|
||||
speed?: BindableFloat;
|
||||
|
||||
// API Input
|
||||
fallback_color?: number[];
|
||||
fallback_color?: BindableColor;
|
||||
timeout?: BindableFloat;
|
||||
interpolation?: string;
|
||||
|
||||
// Notification
|
||||
notification_effect?: string;
|
||||
duration_ms?: number;
|
||||
default_color?: string;
|
||||
default_color?: BindableColor | string;
|
||||
app_colors?: Record<string, string>;
|
||||
app_filter_mode?: string;
|
||||
app_filter_list?: string[];
|
||||
@@ -280,79 +308,193 @@ export interface PatternTemplate {
|
||||
|
||||
export type ValueSourceType =
|
||||
| 'static' | 'animated' | 'audio'
|
||||
| 'adaptive_time' | 'adaptive_scene' | 'daylight';
|
||||
| 'adaptive_time' | 'adaptive_scene' | 'daylight'
|
||||
| 'static_color' | 'animated_color' | 'adaptive_time_color'
|
||||
| 'ha_entity' | 'gradient_map' | 'css_extract';
|
||||
|
||||
export interface SchedulePoint {
|
||||
time: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ValueSource {
|
||||
export interface ColorSchedulePoint {
|
||||
time: string;
|
||||
color: number[];
|
||||
}
|
||||
|
||||
interface ValueSourceBase {
|
||||
id: string;
|
||||
name: string;
|
||||
source_type: ValueSourceType;
|
||||
return_type: 'float' | 'color';
|
||||
description?: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
// Static
|
||||
value?: number;
|
||||
|
||||
// Animated
|
||||
waveform?: string;
|
||||
speed?: number;
|
||||
min_value?: number;
|
||||
max_value?: number;
|
||||
|
||||
// Audio
|
||||
audio_source_id?: string;
|
||||
mode?: string;
|
||||
sensitivity?: number;
|
||||
smoothing?: number;
|
||||
auto_gain?: boolean;
|
||||
|
||||
// Adaptive
|
||||
schedule?: SchedulePoint[];
|
||||
picture_source_id?: string;
|
||||
scene_behavior?: string;
|
||||
|
||||
// Daylight
|
||||
use_real_time?: boolean;
|
||||
latitude?: number;
|
||||
}
|
||||
|
||||
export interface StaticValueSource extends ValueSourceBase {
|
||||
source_type: 'static';
|
||||
return_type: 'float';
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AnimatedValueSource extends ValueSourceBase {
|
||||
source_type: 'animated';
|
||||
return_type: 'float';
|
||||
waveform: string;
|
||||
speed: number;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
}
|
||||
|
||||
export interface AudioValueSource extends ValueSourceBase {
|
||||
source_type: 'audio';
|
||||
return_type: 'float';
|
||||
audio_source_id: string;
|
||||
mode: string;
|
||||
sensitivity: number;
|
||||
smoothing: number;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
auto_gain: boolean;
|
||||
}
|
||||
|
||||
export interface AdaptiveTimeValueSource extends ValueSourceBase {
|
||||
source_type: 'adaptive_time';
|
||||
return_type: 'float';
|
||||
schedule: SchedulePoint[];
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
}
|
||||
|
||||
export interface AdaptiveSceneValueSource extends ValueSourceBase {
|
||||
source_type: 'adaptive_scene';
|
||||
return_type: 'float';
|
||||
picture_source_id: string;
|
||||
scene_behavior: string;
|
||||
sensitivity: number;
|
||||
smoothing: number;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
}
|
||||
|
||||
export interface DaylightValueSource extends ValueSourceBase {
|
||||
source_type: 'daylight';
|
||||
return_type: 'float';
|
||||
speed: number;
|
||||
use_real_time: boolean;
|
||||
latitude: number;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
}
|
||||
|
||||
export interface StaticColorValueSource extends ValueSourceBase {
|
||||
source_type: 'static_color';
|
||||
return_type: 'color';
|
||||
color: number[];
|
||||
}
|
||||
|
||||
export interface AnimatedColorValueSource extends ValueSourceBase {
|
||||
source_type: 'animated_color';
|
||||
return_type: 'color';
|
||||
colors: number[][];
|
||||
speed: number;
|
||||
easing: string;
|
||||
}
|
||||
|
||||
export interface AdaptiveTimeColorValueSource extends ValueSourceBase {
|
||||
source_type: 'adaptive_time_color';
|
||||
return_type: 'color';
|
||||
schedule: ColorSchedulePoint[];
|
||||
}
|
||||
|
||||
export interface HAEntityValueSource extends ValueSourceBase {
|
||||
source_type: 'ha_entity';
|
||||
return_type: 'float';
|
||||
ha_source_id: string;
|
||||
entity_id: string;
|
||||
attribute: string;
|
||||
min_ha_value: number;
|
||||
max_ha_value: number;
|
||||
smoothing: number;
|
||||
}
|
||||
|
||||
export interface GradientMapValueSource extends ValueSourceBase {
|
||||
source_type: 'gradient_map';
|
||||
return_type: 'color';
|
||||
value_source_id: string;
|
||||
gradient_id: string;
|
||||
easing: string;
|
||||
}
|
||||
|
||||
export interface CSSExtractValueSource extends ValueSourceBase {
|
||||
source_type: 'css_extract';
|
||||
return_type: 'color';
|
||||
color_strip_source_id: string;
|
||||
led_start: number;
|
||||
led_end: number;
|
||||
}
|
||||
|
||||
export type ValueSource =
|
||||
| StaticValueSource
|
||||
| AnimatedValueSource
|
||||
| AudioValueSource
|
||||
| AdaptiveTimeValueSource
|
||||
| AdaptiveSceneValueSource
|
||||
| DaylightValueSource
|
||||
| StaticColorValueSource
|
||||
| AnimatedColorValueSource
|
||||
| AdaptiveTimeColorValueSource
|
||||
| HAEntityValueSource
|
||||
| GradientMapValueSource
|
||||
| CSSExtractValueSource;
|
||||
|
||||
// ── Audio Source ───────────────────────────────────────────────
|
||||
|
||||
export interface AudioSource {
|
||||
export type AudioSourceType = 'multichannel' | 'mono' | 'band_extract';
|
||||
|
||||
interface AudioSourceBase {
|
||||
id: string;
|
||||
name: string;
|
||||
source_type: 'multichannel' | 'mono' | 'band_extract';
|
||||
source_type: AudioSourceType;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
// Multichannel
|
||||
device_index?: number;
|
||||
is_loopback?: boolean;
|
||||
audio_template_id?: string;
|
||||
|
||||
// Mono
|
||||
audio_source_id?: string;
|
||||
channel?: string;
|
||||
|
||||
// Band Extract
|
||||
band?: string;
|
||||
freq_low?: number;
|
||||
freq_high?: number;
|
||||
}
|
||||
|
||||
export interface MultichannelAudioSource extends AudioSourceBase {
|
||||
source_type: 'multichannel';
|
||||
device_index: number;
|
||||
is_loopback: boolean;
|
||||
audio_template_id?: string;
|
||||
}
|
||||
|
||||
export interface MonoAudioSource extends AudioSourceBase {
|
||||
source_type: 'mono';
|
||||
audio_source_id: string;
|
||||
channel: string;
|
||||
}
|
||||
|
||||
export interface BandExtractAudioSource extends AudioSourceBase {
|
||||
source_type: 'band_extract';
|
||||
audio_source_id: string;
|
||||
band: string;
|
||||
freq_low: number;
|
||||
freq_high: number;
|
||||
}
|
||||
|
||||
export type AudioSource =
|
||||
| MultichannelAudioSource
|
||||
| MonoAudioSource
|
||||
| BandExtractAudioSource;
|
||||
|
||||
// ── Picture Source ─────────────────────────────────────────────
|
||||
|
||||
export type PictureSourceType = 'raw' | 'processed' | 'static_image' | 'video';
|
||||
|
||||
export interface PictureSource {
|
||||
interface PictureSourceBase {
|
||||
id: string;
|
||||
name: string;
|
||||
stream_type: PictureSourceType;
|
||||
@@ -360,29 +502,44 @@ export interface PictureSource {
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Raw
|
||||
display_index?: number;
|
||||
capture_template_id?: string;
|
||||
target_fps?: number;
|
||||
export interface RawPictureSource extends PictureSourceBase {
|
||||
stream_type: 'raw';
|
||||
display_index: number;
|
||||
capture_template_id: string;
|
||||
target_fps: number;
|
||||
}
|
||||
|
||||
// Processed
|
||||
source_stream_id?: string;
|
||||
postprocessing_template_id?: string;
|
||||
export interface ProcessedPictureSource extends PictureSourceBase {
|
||||
stream_type: 'processed';
|
||||
source_stream_id: string;
|
||||
postprocessing_template_id: string;
|
||||
}
|
||||
|
||||
// Static image
|
||||
export interface StaticImagePictureSource extends PictureSourceBase {
|
||||
stream_type: 'static_image';
|
||||
image_asset_id?: string;
|
||||
}
|
||||
|
||||
// Video
|
||||
export interface VideoPictureSource extends PictureSourceBase {
|
||||
stream_type: 'video';
|
||||
video_asset_id?: string;
|
||||
loop?: boolean;
|
||||
playback_speed?: number;
|
||||
loop: boolean;
|
||||
playback_speed: number;
|
||||
start_time?: number;
|
||||
end_time?: number;
|
||||
resolution_limit?: number;
|
||||
clock_id?: string;
|
||||
target_fps: number;
|
||||
}
|
||||
|
||||
export type PictureSource =
|
||||
| RawPictureSource
|
||||
| ProcessedPictureSource
|
||||
| StaticImagePictureSource
|
||||
| VideoPictureSource;
|
||||
|
||||
// ── Scene Preset ──────────────────────────────────────────────
|
||||
|
||||
export interface TargetSnapshot {
|
||||
|
||||
@@ -1452,6 +1452,53 @@
|
||||
"value_source.type.adaptive_scene.desc": "Adjusts by scene content",
|
||||
"value_source.type.daylight": "Daylight Cycle",
|
||||
"value_source.type.daylight.desc": "Brightness follows day/night cycle",
|
||||
"value_source.type.static_color": "Static Color",
|
||||
"value_source.type.static_color.desc": "Fixed RGB color",
|
||||
"value_source.type.animated_color": "Animated Color",
|
||||
"value_source.type.animated_color.desc": "Cycles through colors",
|
||||
"value_source.type.adaptive_time_color": "Time Color",
|
||||
"value_source.type.adaptive_time_color.desc": "24-hour color schedule",
|
||||
"value_source.type.ha_entity": "HA Entity",
|
||||
"value_source.type.ha_entity.desc": "Reads value from a Home Assistant sensor",
|
||||
"value_source.type.gradient_map": "Gradient Map",
|
||||
"value_source.type.gradient_map.desc": "Maps numeric value through a color gradient",
|
||||
"value_source.type.css_extract": "Strip Extract",
|
||||
"value_source.type.css_extract.desc": "Extracts color from a color strip source",
|
||||
"value_source.ha_source": "HA Connection:",
|
||||
"value_source.ha_source.hint": "Home Assistant connection to read entities from",
|
||||
"value_source.entity_id": "Entity:",
|
||||
"value_source.entity_id.hint": "HA entity ID (e.g. sensor.temperature)",
|
||||
"value_source.attribute": "Attribute (optional):",
|
||||
"value_source.attribute.hint": "Read a specific attribute instead of the entity state",
|
||||
"value_source.min_ha_value": "Min HA Value:",
|
||||
"value_source.min_ha_value.hint": "Raw HA value that maps to 0% output",
|
||||
"value_source.max_ha_value": "Max HA Value:",
|
||||
"value_source.max_ha_value.hint": "Raw HA value that maps to 100% output",
|
||||
"value_source.input_source": "Input Value Source:",
|
||||
"value_source.input_source.hint": "Float value source (0-1) to map through the gradient",
|
||||
"value_source.gradient_stops": "Gradient:",
|
||||
"value_source.gradient_stops.hint": "Color stops for the gradient. Position 0 = input value 0, position 1 = input value 1",
|
||||
"value_source.easing": "Interpolation:",
|
||||
"value_source.easing.hint": "How colors blend between stops",
|
||||
"value_source.css_source": "Color Strip Source:",
|
||||
"value_source.css_source.hint": "Color strip source to extract color from",
|
||||
"value_source.led_start": "LED Start:",
|
||||
"value_source.led_start.hint": "First LED in the range (0-based)",
|
||||
"value_source.led_end": "LED End:",
|
||||
"value_source.led_end.hint": "Last LED in the range (-1 = whole strip)",
|
||||
"value_source.filter.all": "All",
|
||||
"value_source.filter.float": "Numeric",
|
||||
"value_source.filter.color": "Color",
|
||||
"value_source.static_color.color": "Color:",
|
||||
"value_source.animated_color.colors": "Colors:",
|
||||
"value_source.animated_color.speed": "Speed (cpm):",
|
||||
"value_source.animated_color.easing": "Easing:",
|
||||
"value_source.animated_color.easing.linear": "Linear",
|
||||
"value_source.animated_color.easing.linear.desc": "Smooth blend between colors",
|
||||
"value_source.animated_color.easing.step": "Step",
|
||||
"value_source.animated_color.easing.step.desc": "Instant jump between colors",
|
||||
"value_source.animated_color.color_count": "colors",
|
||||
"value_source.adaptive_time_color.schedule": "Color Schedule:",
|
||||
"value_source.daylight.speed": "Speed:",
|
||||
"value_source.daylight.speed.hint": "Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.",
|
||||
"value_source.daylight.use_real_time": "Use Real Time:",
|
||||
@@ -1530,6 +1577,8 @@
|
||||
"test.frames": "Frames",
|
||||
"test.fps": "FPS",
|
||||
"test.avg_capture": "Avg",
|
||||
"targets.brightness": "Brightness:",
|
||||
"targets.brightness.hint": "Output brightness multiplier (0–1). Can be bound to a value source for dynamic control.",
|
||||
"targets.brightness_vs": "Brightness Source:",
|
||||
"targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)",
|
||||
"targets.brightness_vs.none": "None (device brightness)",
|
||||
|
||||
@@ -1440,6 +1440,8 @@
|
||||
"test.frames": "Кадры",
|
||||
"test.fps": "Кадр/с",
|
||||
"test.avg_capture": "Сред",
|
||||
"targets.brightness": "Яркость:",
|
||||
"targets.brightness.hint": "Множитель яркости (0–1). Можно привязать к источнику значений для динамического управления.",
|
||||
"targets.brightness_vs": "Источник яркости:",
|
||||
"targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)",
|
||||
"targets.brightness_vs.none": "Нет (яркость устройства)",
|
||||
|
||||
@@ -1440,6 +1440,8 @@
|
||||
"test.frames": "帧数",
|
||||
"test.fps": "帧率",
|
||||
"test.avg_capture": "平均",
|
||||
"targets.brightness": "亮度:",
|
||||
"targets.brightness.hint": "输出亮度乘数(0–1)。可绑定到值源进行动态控制。",
|
||||
"targets.brightness_vs": "亮度源:",
|
||||
"targets.brightness_vs.hint": "可选的值源,每帧动态控制亮度(覆盖设备亮度)",
|
||||
"targets.brightness_vs.none": "无(设备亮度)",
|
||||
|
||||
@@ -100,6 +100,76 @@ class BindableFloat:
|
||||
return bool(self.source_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BindableColor:
|
||||
"""An RGB color that is either static or driven by a color ValueSource."""
|
||||
|
||||
color: list # [R, G, B] static fallback
|
||||
source_id: str = "" # empty → use static color
|
||||
|
||||
def __post_init__(self):
|
||||
if not isinstance(self.color, list) or len(self.color) != 3:
|
||||
self.color = [255, 255, 255]
|
||||
|
||||
def to_dict(self):
|
||||
"""Serialize: plain [R,G,B] when unbound, dict when bound."""
|
||||
if not self.source_id:
|
||||
return list(self.color)
|
||||
return {"color": list(self.color), "source_id": self.source_id}
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, data, *, default: list | None = None) -> "BindableColor":
|
||||
"""Deserialize from [R,G,B] array or dict."""
|
||||
if default is None:
|
||||
default = [255, 255, 255]
|
||||
if data is None:
|
||||
return cls(color=list(default))
|
||||
if isinstance(data, list) and len(data) == 3:
|
||||
return cls(color=list(data))
|
||||
if isinstance(data, dict):
|
||||
raw_color = data.get("color", default)
|
||||
color = (
|
||||
list(raw_color)
|
||||
if isinstance(raw_color, list) and len(raw_color) == 3
|
||||
else list(default)
|
||||
)
|
||||
return cls(color=color, source_id=data.get("source_id") or "")
|
||||
return cls(color=list(default))
|
||||
|
||||
def apply_update(self, raw) -> "BindableColor":
|
||||
"""Return a new BindableColor from an update payload."""
|
||||
if raw is None:
|
||||
return self
|
||||
if isinstance(raw, list) and len(raw) == 3:
|
||||
return BindableColor(color=list(raw), source_id=self.source_id)
|
||||
if isinstance(raw, dict):
|
||||
color = raw.get("color", self.color)
|
||||
if isinstance(color, list) and len(color) == 3:
|
||||
color = list(color)
|
||||
else:
|
||||
color = list(self.color)
|
||||
return BindableColor(
|
||||
color=color,
|
||||
source_id=raw.get("source_id", self.source_id),
|
||||
)
|
||||
return self
|
||||
|
||||
@property
|
||||
def is_bound(self) -> bool:
|
||||
return bool(self.source_id)
|
||||
|
||||
|
||||
def bcolor(v, default: list | None = None) -> list:
|
||||
"""Extract the static [R,G,B] from a value that may be BindableColor or plain list."""
|
||||
if default is None:
|
||||
default = [255, 255, 255]
|
||||
if isinstance(v, BindableColor):
|
||||
return list(v.color)
|
||||
if isinstance(v, list) and len(v) == 3:
|
||||
return list(v)
|
||||
return list(default)
|
||||
|
||||
|
||||
def bfloat(v, default: float = 0.0) -> float:
|
||||
"""Extract the static float from a value that may be BindableFloat or plain number.
|
||||
|
||||
|
||||
@@ -26,10 +26,27 @@ from wled_controller.core.capture.calibration import (
|
||||
calibration_from_dict,
|
||||
calibration_to_dict,
|
||||
)
|
||||
from wled_controller.storage.bindable import BindableFloat
|
||||
from wled_controller.storage.bindable import BindableColor, BindableFloat
|
||||
from wled_controller.storage.utils import resolve_ref
|
||||
|
||||
|
||||
def _parse_default_color(raw) -> BindableColor:
|
||||
"""Parse default_color from legacy hex string or BindableColor format."""
|
||||
if raw is None:
|
||||
return BindableColor([255, 255, 255])
|
||||
if isinstance(raw, str):
|
||||
# Legacy hex string like "#FFFFFF"
|
||||
h = raw.lstrip("#")
|
||||
if len(h) == 6:
|
||||
try:
|
||||
rgb = [int(h[i : i + 2], 16) for i in (0, 2, 4)]
|
||||
return BindableColor(rgb)
|
||||
except ValueError:
|
||||
pass
|
||||
return BindableColor([255, 255, 255])
|
||||
return BindableColor.from_raw(raw, default=[255, 255, 255])
|
||||
|
||||
|
||||
def _validate_rgb(value, default: list) -> list:
|
||||
"""Return value if it's a 3-element list, otherwise return default."""
|
||||
return value if isinstance(value, list) and len(value) == 3 else list(default)
|
||||
@@ -349,23 +366,22 @@ class StaticColorStripSource(ColorStripSource):
|
||||
a PictureColorStripSource is being configured.
|
||||
"""
|
||||
|
||||
color: list = field(default_factory=lambda: [255, 255, 255]) # [R, G, B]
|
||||
color: BindableColor = field(default_factory=lambda: BindableColor([255, 255, 255]))
|
||||
animation: Optional[dict] = None # {"enabled": bool, "type": str, "speed": float} or None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["color"] = list(self.color)
|
||||
d["color"] = self.color.to_dict()
|
||||
d["animation"] = self.animation
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "StaticColorStripSource":
|
||||
common = _parse_css_common(data)
|
||||
color = _validate_rgb(data.get("color"), [255, 255, 255])
|
||||
return cls(
|
||||
**common,
|
||||
source_type="static",
|
||||
color=color,
|
||||
color=BindableColor.from_raw(data.get("color"), default=[255, 255, 255]),
|
||||
animation=data.get("animation"),
|
||||
)
|
||||
|
||||
@@ -385,7 +401,6 @@ class StaticColorStripSource(ColorStripSource):
|
||||
animation=None,
|
||||
**_kwargs,
|
||||
):
|
||||
rgb = _validate_rgb(color, [255, 255, 255])
|
||||
return cls(
|
||||
id=id,
|
||||
name=name,
|
||||
@@ -395,15 +410,14 @@ class StaticColorStripSource(ColorStripSource):
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
tags=tags or [],
|
||||
color=rgb,
|
||||
color=BindableColor.from_raw(color, default=[255, 255, 255]),
|
||||
animation=animation,
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
color = kwargs.get("color")
|
||||
if color is not None:
|
||||
if isinstance(color, list) and len(color) == 3:
|
||||
self.color = color
|
||||
self.color = self.color.apply_update(color)
|
||||
if kwargs.get("animation") is not None:
|
||||
self.animation = kwargs["animation"]
|
||||
|
||||
@@ -591,8 +605,8 @@ class EffectColorStripSource(ColorStripSource):
|
||||
effect_type: str = "fire" # fire | meteor | plasma | noise | aurora + new types
|
||||
palette: str = "fire" # legacy palette name (kept for migration)
|
||||
gradient_id: Optional[str] = None # references a Gradient entity (preferred over palette)
|
||||
color: list = field(
|
||||
default_factory=lambda: [255, 80, 0]
|
||||
color: BindableColor = field(
|
||||
default_factory=lambda: BindableColor([255, 80, 0])
|
||||
) # [R,G,B] for meteor/comet/bouncing_ball head
|
||||
intensity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||
scale: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||
@@ -604,7 +618,7 @@ class EffectColorStripSource(ColorStripSource):
|
||||
d["effect_type"] = self.effect_type
|
||||
d["palette"] = self.palette
|
||||
d["gradient_id"] = self.gradient_id
|
||||
d["color"] = list(self.color)
|
||||
d["color"] = self.color.to_dict()
|
||||
d["intensity"] = self.intensity.to_dict()
|
||||
d["scale"] = self.scale.to_dict()
|
||||
d["mirror"] = self.mirror
|
||||
@@ -614,14 +628,13 @@ class EffectColorStripSource(ColorStripSource):
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "EffectColorStripSource":
|
||||
common = _parse_css_common(data)
|
||||
color = _validate_rgb(data.get("color"), [255, 80, 0])
|
||||
return cls(
|
||||
**common,
|
||||
source_type="effect",
|
||||
effect_type=data.get("effect_type") or "fire",
|
||||
palette=data.get("palette") or "fire",
|
||||
gradient_id=data.get("gradient_id"),
|
||||
color=color,
|
||||
color=BindableColor.from_raw(data.get("color"), default=[255, 80, 0]),
|
||||
intensity=BindableFloat.from_raw(data.get("intensity"), default=1.0),
|
||||
scale=BindableFloat.from_raw(data.get("scale"), default=1.0),
|
||||
mirror=bool(data.get("mirror", False)),
|
||||
@@ -650,7 +663,6 @@ class EffectColorStripSource(ColorStripSource):
|
||||
custom_palette=None,
|
||||
**_kwargs,
|
||||
):
|
||||
rgb = _validate_rgb(color, [255, 80, 0])
|
||||
return cls(
|
||||
id=id,
|
||||
name=name,
|
||||
@@ -663,7 +675,7 @@ class EffectColorStripSource(ColorStripSource):
|
||||
effect_type=effect_type or "fire",
|
||||
palette=palette or "fire",
|
||||
gradient_id=gradient_id,
|
||||
color=rgb,
|
||||
color=BindableColor.from_raw(color, default=[255, 80, 0]),
|
||||
intensity=BindableFloat.from_raw(intensity, default=1.0),
|
||||
scale=BindableFloat.from_raw(scale, default=1.0),
|
||||
mirror=bool(mirror),
|
||||
@@ -678,8 +690,8 @@ class EffectColorStripSource(ColorStripSource):
|
||||
if "gradient_id" in kwargs:
|
||||
self.gradient_id = kwargs["gradient_id"]
|
||||
color = kwargs.get("color")
|
||||
if color is not None and isinstance(color, list) and len(color) == 3:
|
||||
self.color = color
|
||||
if color is not None:
|
||||
self.color = self.color.apply_update(color)
|
||||
if kwargs.get("intensity") is not None:
|
||||
self.intensity = self.intensity.apply_update(kwargs["intensity"])
|
||||
if kwargs.get("scale") is not None:
|
||||
@@ -706,8 +718,8 @@ class AudioColorStripSource(ColorStripSource):
|
||||
smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
|
||||
palette: str = "rainbow" # legacy palette name (kept for migration)
|
||||
gradient_id: Optional[str] = None # references a Gradient entity (preferred)
|
||||
color: list = field(default_factory=lambda: [0, 255, 0]) # base RGB for VU meter
|
||||
color_peak: list = field(default_factory=lambda: [255, 0, 0]) # peak RGB for VU meter
|
||||
color: BindableColor = field(default_factory=lambda: BindableColor([0, 255, 0]))
|
||||
color_peak: BindableColor = field(default_factory=lambda: BindableColor([255, 0, 0]))
|
||||
led_count: int = 0 # 0 = use device LED count
|
||||
mirror: bool = False # mirror spectrum from center outward
|
||||
|
||||
@@ -719,8 +731,8 @@ class AudioColorStripSource(ColorStripSource):
|
||||
d["smoothing"] = self.smoothing.to_dict()
|
||||
d["palette"] = self.palette
|
||||
d["gradient_id"] = self.gradient_id
|
||||
d["color"] = list(self.color)
|
||||
d["color_peak"] = list(self.color_peak)
|
||||
d["color"] = self.color.to_dict()
|
||||
d["color_peak"] = self.color_peak.to_dict()
|
||||
d["led_count"] = self.led_count
|
||||
d["mirror"] = self.mirror
|
||||
return d
|
||||
@@ -728,8 +740,6 @@ class AudioColorStripSource(ColorStripSource):
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "AudioColorStripSource":
|
||||
common = _parse_css_common(data)
|
||||
color = _validate_rgb(data.get("color"), [0, 255, 0])
|
||||
color_peak = _validate_rgb(data.get("color_peak"), [255, 0, 0])
|
||||
return cls(
|
||||
**common,
|
||||
source_type="audio",
|
||||
@@ -739,8 +749,8 @@ class AudioColorStripSource(ColorStripSource):
|
||||
smoothing=BindableFloat.from_raw(data.get("smoothing"), default=0.3),
|
||||
palette=data.get("palette") or "rainbow",
|
||||
gradient_id=data.get("gradient_id"),
|
||||
color=color,
|
||||
color_peak=color_peak,
|
||||
color=BindableColor.from_raw(data.get("color"), default=[0, 255, 0]),
|
||||
color_peak=BindableColor.from_raw(data.get("color_peak"), default=[255, 0, 0]),
|
||||
led_count=data.get("led_count") or 0,
|
||||
mirror=bool(data.get("mirror", False)),
|
||||
)
|
||||
@@ -769,8 +779,6 @@ class AudioColorStripSource(ColorStripSource):
|
||||
mirror=False,
|
||||
**_kwargs,
|
||||
):
|
||||
rgb = _validate_rgb(color, [0, 255, 0])
|
||||
peak = _validate_rgb(color_peak, [255, 0, 0])
|
||||
return cls(
|
||||
id=id,
|
||||
name=name,
|
||||
@@ -786,8 +794,8 @@ class AudioColorStripSource(ColorStripSource):
|
||||
smoothing=BindableFloat.from_raw(smoothing, default=0.3),
|
||||
palette=palette or "rainbow",
|
||||
gradient_id=gradient_id,
|
||||
color=rgb,
|
||||
color_peak=peak,
|
||||
color=BindableColor.from_raw(color, default=[0, 255, 0]),
|
||||
color_peak=BindableColor.from_raw(color_peak, default=[255, 0, 0]),
|
||||
led_count=led_count,
|
||||
mirror=bool(mirror),
|
||||
)
|
||||
@@ -807,11 +815,11 @@ class AudioColorStripSource(ColorStripSource):
|
||||
if "gradient_id" in kwargs:
|
||||
self.gradient_id = kwargs["gradient_id"]
|
||||
color = kwargs.get("color")
|
||||
if color is not None and isinstance(color, list) and len(color) == 3:
|
||||
self.color = color
|
||||
if color is not None:
|
||||
self.color = self.color.apply_update(color)
|
||||
color_peak = kwargs.get("color_peak")
|
||||
if color_peak is not None and isinstance(color_peak, list) and len(color_peak) == 3:
|
||||
self.color_peak = color_peak
|
||||
if color_peak is not None:
|
||||
self.color_peak = self.color_peak.apply_update(color_peak)
|
||||
if kwargs.get("led_count") is not None:
|
||||
self.led_count = kwargs["led_count"]
|
||||
if kwargs.get("mirror") is not None:
|
||||
@@ -963,13 +971,13 @@ class ApiInputColorStripSource(ColorStripSource):
|
||||
LED count auto-sizes from the connected device via configure().
|
||||
"""
|
||||
|
||||
fallback_color: list = field(default_factory=lambda: [0, 0, 0]) # [R, G, B]
|
||||
fallback_color: BindableColor = field(default_factory=lambda: BindableColor([0, 0, 0]))
|
||||
timeout: BindableFloat = field(default_factory=lambda: BindableFloat(5.0))
|
||||
interpolation: str = "linear" # none | linear | nearest
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["fallback_color"] = list(self.fallback_color)
|
||||
d["fallback_color"] = self.fallback_color.to_dict()
|
||||
d["timeout"] = self.timeout.to_dict()
|
||||
d["interpolation"] = self.interpolation
|
||||
return d
|
||||
@@ -977,7 +985,7 @@ class ApiInputColorStripSource(ColorStripSource):
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ApiInputColorStripSource":
|
||||
common = _parse_css_common(data)
|
||||
fallback_color = _validate_rgb(data.get("fallback_color"), [0, 0, 0])
|
||||
fallback_color = BindableColor.from_raw(data.get("fallback_color"), default=[0, 0, 0])
|
||||
interpolation = data.get("interpolation", "linear")
|
||||
if interpolation not in ("none", "linear", "nearest"):
|
||||
interpolation = "linear"
|
||||
@@ -1006,7 +1014,7 @@ class ApiInputColorStripSource(ColorStripSource):
|
||||
interpolation=None,
|
||||
**_kwargs,
|
||||
):
|
||||
fb = _validate_rgb(fallback_color, [0, 0, 0])
|
||||
fb = BindableColor.from_raw(fallback_color, default=[0, 0, 0])
|
||||
interp = interpolation if interpolation in ("none", "linear", "nearest") else "linear"
|
||||
return cls(
|
||||
id=id,
|
||||
@@ -1024,12 +1032,8 @@ class ApiInputColorStripSource(ColorStripSource):
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
fallback_color = kwargs.get("fallback_color")
|
||||
if (
|
||||
fallback_color is not None
|
||||
and isinstance(fallback_color, list)
|
||||
and len(fallback_color) == 3
|
||||
):
|
||||
self.fallback_color = fallback_color
|
||||
if fallback_color is not None:
|
||||
self.fallback_color = self.fallback_color.apply_update(fallback_color)
|
||||
if kwargs.get("timeout") is not None:
|
||||
self.timeout = self.timeout.apply_update(kwargs["timeout"])
|
||||
interpolation = kwargs.get("interpolation")
|
||||
@@ -1050,7 +1054,7 @@ class NotificationColorStripSource(ColorStripSource):
|
||||
|
||||
notification_effect: str = "flash" # flash | pulse | sweep
|
||||
duration_ms: BindableFloat = field(default_factory=lambda: BindableFloat(1500.0))
|
||||
default_color: str = "#FFFFFF" # hex color for notifications without app match
|
||||
default_color: BindableColor = field(default_factory=lambda: BindableColor([255, 255, 255]))
|
||||
app_colors: dict = field(default_factory=dict) # app name -> hex color
|
||||
app_filter_mode: str = "off" # off | whitelist | blacklist
|
||||
app_filter_list: list = field(default_factory=list) # app names for filter
|
||||
@@ -1065,7 +1069,7 @@ class NotificationColorStripSource(ColorStripSource):
|
||||
d = super().to_dict()
|
||||
d["notification_effect"] = self.notification_effect
|
||||
d["duration_ms"] = self.duration_ms.to_dict()
|
||||
d["default_color"] = self.default_color
|
||||
d["default_color"] = self.default_color.to_dict()
|
||||
d["app_colors"] = dict(self.app_colors)
|
||||
d["app_filter_mode"] = self.app_filter_mode
|
||||
d["app_filter_list"] = list(self.app_filter_list)
|
||||
@@ -1086,7 +1090,7 @@ class NotificationColorStripSource(ColorStripSource):
|
||||
source_type="notification",
|
||||
notification_effect=data.get("notification_effect") or "flash",
|
||||
duration_ms=BindableFloat.from_raw(data.get("duration_ms"), default=1500.0),
|
||||
default_color=data.get("default_color") or "#FFFFFF",
|
||||
default_color=_parse_default_color(data.get("default_color")),
|
||||
app_colors=raw_app_colors if isinstance(raw_app_colors, dict) else {},
|
||||
app_filter_mode=data.get("app_filter_mode") or "off",
|
||||
app_filter_list=raw_app_filter_list if isinstance(raw_app_filter_list, list) else [],
|
||||
@@ -1131,7 +1135,7 @@ class NotificationColorStripSource(ColorStripSource):
|
||||
tags=tags or [],
|
||||
notification_effect=notification_effect or "flash",
|
||||
duration_ms=BindableFloat.from_raw(duration_ms, default=1500.0),
|
||||
default_color=default_color or "#FFFFFF",
|
||||
default_color=_parse_default_color(default_color),
|
||||
app_colors=app_colors if isinstance(app_colors, dict) else {},
|
||||
app_filter_mode=app_filter_mode or "off",
|
||||
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
|
||||
@@ -1147,7 +1151,11 @@ class NotificationColorStripSource(ColorStripSource):
|
||||
if kwargs.get("duration_ms") is not None:
|
||||
self.duration_ms = self.duration_ms.apply_update(kwargs["duration_ms"])
|
||||
if kwargs.get("default_color") is not None:
|
||||
self.default_color = kwargs["default_color"]
|
||||
raw_dc = kwargs["default_color"]
|
||||
if isinstance(raw_dc, str):
|
||||
self.default_color = _parse_default_color(raw_dc)
|
||||
else:
|
||||
self.default_color = self.default_color.apply_update(raw_dc)
|
||||
app_colors = kwargs.get("app_colors")
|
||||
if app_colors is not None and isinstance(app_colors, dict):
|
||||
self.app_colors = app_colors
|
||||
@@ -1259,7 +1267,7 @@ class CandlelightColorStripSource(ColorStripSource):
|
||||
LED count auto-sizes from the connected device.
|
||||
"""
|
||||
|
||||
color: list = field(default_factory=lambda: [255, 147, 41]) # warm candle base [R,G,B]
|
||||
color: BindableColor = field(default_factory=lambda: BindableColor([255, 147, 41]))
|
||||
intensity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||
num_candles: int = 3 # number of independent candle sources
|
||||
speed: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||
@@ -1268,7 +1276,7 @@ class CandlelightColorStripSource(ColorStripSource):
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["color"] = list(self.color)
|
||||
d["color"] = self.color.to_dict()
|
||||
d["intensity"] = self.intensity.to_dict()
|
||||
d["num_candles"] = self.num_candles
|
||||
d["speed"] = self.speed.to_dict()
|
||||
@@ -1279,11 +1287,10 @@ class CandlelightColorStripSource(ColorStripSource):
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "CandlelightColorStripSource":
|
||||
common = _parse_css_common(data)
|
||||
color = _validate_rgb(data.get("color"), [255, 147, 41])
|
||||
return cls(
|
||||
**common,
|
||||
source_type="candlelight",
|
||||
color=color,
|
||||
color=BindableColor.from_raw(data.get("color"), default=[255, 147, 41]),
|
||||
intensity=BindableFloat.from_raw(data.get("intensity"), default=1.0),
|
||||
num_candles=int(data.get("num_candles") or 3),
|
||||
speed=BindableFloat.from_raw(data.get("speed"), default=1.0),
|
||||
@@ -1311,7 +1318,6 @@ class CandlelightColorStripSource(ColorStripSource):
|
||||
candle_type=None,
|
||||
**_kwargs,
|
||||
):
|
||||
rgb = _validate_rgb(color, [255, 147, 41])
|
||||
return cls(
|
||||
id=id,
|
||||
name=name,
|
||||
@@ -1321,7 +1327,7 @@ class CandlelightColorStripSource(ColorStripSource):
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
tags=tags or [],
|
||||
color=rgb,
|
||||
color=BindableColor.from_raw(color, default=[255, 147, 41]),
|
||||
intensity=BindableFloat.from_raw(intensity, default=1.0),
|
||||
num_candles=int(num_candles) if num_candles is not None else 3,
|
||||
speed=BindableFloat.from_raw(speed, default=1.0),
|
||||
@@ -1335,8 +1341,8 @@ class CandlelightColorStripSource(ColorStripSource):
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
color = kwargs.get("color")
|
||||
if color is not None and isinstance(color, list) and len(color) == 3:
|
||||
self.color = color
|
||||
if color is not None:
|
||||
self.color = self.color.apply_update(color)
|
||||
if kwargs.get("intensity") is not None:
|
||||
self.intensity = self.intensity.apply_update(kwargs["intensity"])
|
||||
if kwargs.get("num_candles") is not None:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Value source data model with inheritance-based source types.
|
||||
|
||||
A ValueSource produces a scalar float (0.0–1.0) that can drive target
|
||||
parameters like brightness. Six types:
|
||||
A ValueSource produces either a scalar float (0.0–1.0) or an RGB color [R,G,B]
|
||||
depending on ``return_type`` ("float" or "color").
|
||||
|
||||
Float types (return_type="float"):
|
||||
StaticValueSource — constant float value
|
||||
AnimatedValueSource — periodic waveform (sine, triangle, square, sawtooth)
|
||||
AudioValueSource — audio-reactive scalar (RMS, peak, beat detection)
|
||||
@@ -9,6 +11,11 @@ parameters like brightness. Six types:
|
||||
adaptive_time — interpolates brightness along a 24-hour schedule
|
||||
adaptive_scene — derives brightness from a picture source's frame luminance
|
||||
DaylightValueSource — brightness based on simulated or real-time daylight cycle
|
||||
|
||||
Color types (return_type="color"):
|
||||
StaticColorValueSource — constant RGB color
|
||||
AnimatedColorValueSource — cycles through a color list over time
|
||||
AdaptiveTimeColorValueSource — 24-hour schedule of RGB colors
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
@@ -22,7 +29,9 @@ class ValueSource:
|
||||
|
||||
id: str
|
||||
name: str
|
||||
source_type: str # "static" | "animated" | "audio" | "adaptive_time" | "adaptive_scene" | "daylight"
|
||||
source_type: (
|
||||
str # "static" | "animated" | "audio" | "adaptive_time" | "adaptive_scene" | "daylight"
|
||||
)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
@@ -70,15 +79,13 @@ def _parse_common_fields(data: dict) -> dict:
|
||||
created_at = (
|
||||
datetime.fromisoformat(raw_created)
|
||||
if isinstance(raw_created, str)
|
||||
else raw_created if isinstance(raw_created, datetime)
|
||||
else datetime.now(timezone.utc)
|
||||
else raw_created if isinstance(raw_created, datetime) else datetime.now(timezone.utc)
|
||||
)
|
||||
raw_updated = data.get("updated_at")
|
||||
updated_at = (
|
||||
datetime.fromisoformat(raw_updated)
|
||||
if isinstance(raw_updated, str)
|
||||
else raw_updated if isinstance(raw_updated, datetime)
|
||||
else datetime.now(timezone.utc)
|
||||
else raw_updated if isinstance(raw_updated, datetime) else datetime.now(timezone.utc)
|
||||
)
|
||||
return dict(
|
||||
id=data["id"],
|
||||
@@ -122,10 +129,10 @@ class AnimatedValueSource(ValueSource):
|
||||
at the configured speed (cycles per minute).
|
||||
"""
|
||||
|
||||
waveform: str = "sine" # sine | triangle | square | sawtooth
|
||||
speed: float = 10.0 # cycles per minute (1.0–120.0)
|
||||
min_value: float = 0.0 # minimum output (0.0–1.0)
|
||||
max_value: float = 1.0 # maximum output (0.0–1.0)
|
||||
waveform: str = "sine" # sine | triangle | square | sawtooth
|
||||
speed: float = 10.0 # cycles per minute (1.0–120.0)
|
||||
min_value: float = 0.0 # minimum output (0.0–1.0)
|
||||
max_value: float = 1.0 # maximum output (0.0–1.0)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
@@ -156,13 +163,13 @@ class AudioValueSource(ValueSource):
|
||||
into a scalar value for brightness modulation.
|
||||
"""
|
||||
|
||||
audio_source_id: str = "" # references an audio source (mono or multichannel)
|
||||
mode: str = "rms" # rms | peak | beat
|
||||
sensitivity: float = 1.0 # gain multiplier (0.1–20.0)
|
||||
smoothing: float = 0.3 # temporal smoothing (0.0–1.0)
|
||||
min_value: float = 0.0 # minimum output (0.0–1.0)
|
||||
max_value: float = 1.0 # maximum output (0.0–1.0)
|
||||
auto_gain: bool = False # auto-normalize audio levels to full range
|
||||
audio_source_id: str = "" # references an audio source (mono or multichannel)
|
||||
mode: str = "rms" # rms | peak | beat
|
||||
sensitivity: float = 1.0 # gain multiplier (0.1–20.0)
|
||||
smoothing: float = 0.3 # temporal smoothing (0.0–1.0)
|
||||
min_value: float = 0.0 # minimum output (0.0–1.0)
|
||||
max_value: float = 1.0 # maximum output (0.0–1.0)
|
||||
auto_gain: bool = False # auto-normalize audio levels to full range
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
@@ -201,12 +208,12 @@ class AdaptiveValueSource(ValueSource):
|
||||
"""
|
||||
|
||||
schedule: List[dict] = field(default_factory=list) # [{time: "HH:MM", value: 0.0-1.0}]
|
||||
picture_source_id: str = "" # for scene mode
|
||||
scene_behavior: str = "complement" # "complement" | "match"
|
||||
sensitivity: float = 1.0 # gain multiplier (0.1-5.0)
|
||||
smoothing: float = 0.3 # temporal smoothing (0.0-1.0)
|
||||
min_value: float = 0.0 # output range min
|
||||
max_value: float = 1.0 # output range max
|
||||
picture_source_id: str = "" # for scene mode
|
||||
scene_behavior: str = "complement" # "complement" | "match"
|
||||
sensitivity: float = 1.0 # gain multiplier (0.1-5.0)
|
||||
smoothing: float = 0.3 # temporal smoothing (0.0-1.0)
|
||||
min_value: float = 0.0 # output range min
|
||||
max_value: float = 1.0 # output range max
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
@@ -244,11 +251,11 @@ class DaylightValueSource(ValueSource):
|
||||
scalar brightness from the simulated (or real-time) sky color luminance.
|
||||
"""
|
||||
|
||||
speed: float = 1.0 # simulation speed (ignored when use_real_time)
|
||||
use_real_time: bool = False # use wall clock instead of simulation
|
||||
latitude: float = 50.0 # affects sunrise/sunset in real-time mode
|
||||
min_value: float = 0.0 # output range min
|
||||
max_value: float = 1.0 # output range max
|
||||
speed: float = 1.0 # simulation speed (ignored when use_real_time)
|
||||
use_real_time: bool = False # use wall clock instead of simulation
|
||||
latitude: float = 50.0 # affects sunrise/sunset in real-time mode
|
||||
min_value: float = 0.0 # output range min
|
||||
max_value: float = 1.0 # output range max
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
@@ -273,6 +280,193 @@ class DaylightValueSource(ValueSource):
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Color value sources (return_type="color")
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class StaticColorValueSource(ValueSource):
|
||||
"""Color value source that outputs a constant RGB color."""
|
||||
|
||||
color: list = field(default_factory=lambda: [255, 255, 255]) # [R, G, B]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["color"] = list(self.color)
|
||||
d["return_type"] = "color"
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "StaticColorValueSource":
|
||||
common = _parse_common_fields(data)
|
||||
raw = data.get("color", [255, 255, 255])
|
||||
color = list(raw) if isinstance(raw, list) and len(raw) == 3 else [255, 255, 255]
|
||||
return cls(**common, source_type="static_color", color=color)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnimatedColorValueSource(ValueSource):
|
||||
"""Color value source that cycles through a list of colors over time."""
|
||||
|
||||
colors: list = field(default_factory=lambda: [[255, 0, 0], [0, 255, 0], [0, 0, 255]])
|
||||
speed: float = 10.0 # cycles per minute
|
||||
easing: str = "linear" # linear | step
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["colors"] = [list(c) for c in self.colors]
|
||||
d["speed"] = self.speed
|
||||
d["easing"] = self.easing
|
||||
d["return_type"] = "color"
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "AnimatedColorValueSource":
|
||||
common = _parse_common_fields(data)
|
||||
raw_colors = data.get("colors")
|
||||
colors = (
|
||||
[list(c) for c in raw_colors]
|
||||
if isinstance(raw_colors, list) and len(raw_colors) >= 2
|
||||
else [[255, 0, 0], [0, 255, 0], [0, 0, 255]]
|
||||
)
|
||||
return cls(
|
||||
**common,
|
||||
source_type="animated_color",
|
||||
colors=colors,
|
||||
speed=float(data.get("speed") or 10.0),
|
||||
easing=data.get("easing") or "linear",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdaptiveTimeColorValueSource(ValueSource):
|
||||
"""Color value source with a 24-hour schedule of RGB colors."""
|
||||
|
||||
schedule: List[dict] = field(default_factory=list) # [{"time": "HH:MM", "color": [R,G,B]}, ...]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["schedule"] = self.schedule
|
||||
d["return_type"] = "color"
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "AdaptiveTimeColorValueSource":
|
||||
common = _parse_common_fields(data)
|
||||
return cls(
|
||||
**common,
|
||||
source_type="adaptive_time_color",
|
||||
schedule=data.get("schedule") or [],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HAEntityValueSource(ValueSource):
|
||||
"""Value source that reads numeric values from a Home Assistant entity.
|
||||
|
||||
Reads state or attribute from an HA entity, normalizes the raw value
|
||||
to 0.0–1.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.0–1.0)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["ha_source_id"] = self.ha_source_id
|
||||
d["entity_id"] = self.entity_id
|
||||
d["attribute"] = self.attribute
|
||||
d["min_ha_value"] = self.min_ha_value
|
||||
d["max_ha_value"] = self.max_ha_value
|
||||
d["smoothing"] = self.smoothing
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "HAEntityValueSource":
|
||||
common = _parse_common_fields(data)
|
||||
return cls(
|
||||
**common,
|
||||
source_type="ha_entity",
|
||||
ha_source_id=data.get("ha_source_id") or "",
|
||||
entity_id=data.get("entity_id") or "",
|
||||
attribute=data.get("attribute") or "",
|
||||
min_ha_value=float(data.get("min_ha_value") or 0.0),
|
||||
max_ha_value=float(
|
||||
data.get("max_ha_value") if data.get("max_ha_value") is not None else 100.0
|
||||
),
|
||||
smoothing=float(data.get("smoothing") or 0.0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GradientMapValueSource(ValueSource):
|
||||
"""Color value source that maps a float value source through a gradient.
|
||||
|
||||
Takes a float-returning value source (0..1), interpolates the color at
|
||||
that position in the gradient referenced by gradient_id.
|
||||
"""
|
||||
|
||||
value_source_id: str = "" # references a float-returning ValueSource
|
||||
gradient_id: str = "" # references a Gradient entity
|
||||
easing: str = "linear" # linear | step
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["value_source_id"] = self.value_source_id
|
||||
d["gradient_id"] = self.gradient_id
|
||||
d["easing"] = self.easing
|
||||
d["return_type"] = "color"
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "GradientMapValueSource":
|
||||
common = _parse_common_fields(data)
|
||||
return cls(
|
||||
**common,
|
||||
source_type="gradient_map",
|
||||
value_source_id=data.get("value_source_id") or "",
|
||||
gradient_id=data.get("gradient_id") or "",
|
||||
easing=data.get("easing") or "linear",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CSSExtractValueSource(ValueSource):
|
||||
"""Color value source that extracts a single color from a color strip source.
|
||||
|
||||
Averages the colors in the specified LED range of a color strip source
|
||||
to produce a single RGB color output.
|
||||
"""
|
||||
|
||||
color_strip_source_id: str = "" # references a ColorStripSource
|
||||
led_start: int = 0 # start of LED range (0-based)
|
||||
led_end: int = -1 # end of LED range (-1 = whole strip)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["color_strip_source_id"] = self.color_strip_source_id
|
||||
d["led_start"] = self.led_start
|
||||
d["led_end"] = self.led_end
|
||||
d["return_type"] = "color"
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "CSSExtractValueSource":
|
||||
common = _parse_common_fields(data)
|
||||
return cls(
|
||||
**common,
|
||||
source_type="css_extract",
|
||||
color_strip_source_id=data.get("color_strip_source_id") or "",
|
||||
led_start=int(data.get("led_start") or 0),
|
||||
led_end=int(data.get("led_end") if data.get("led_end") is not None else -1),
|
||||
)
|
||||
|
||||
|
||||
# -- Source type registry --
|
||||
# Maps source_type string to its subclass for factory dispatch.
|
||||
_VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = {
|
||||
@@ -282,4 +476,10 @@ _VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = {
|
||||
"adaptive_time": AdaptiveValueSource,
|
||||
"adaptive_scene": AdaptiveValueSource,
|
||||
"daylight": DaylightValueSource,
|
||||
"static_color": StaticColorValueSource,
|
||||
"animated_color": AnimatedColorValueSource,
|
||||
"adaptive_time_color": AdaptiveTimeColorValueSource,
|
||||
"ha_entity": HAEntityValueSource,
|
||||
"gradient_map": GradientMapValueSource,
|
||||
"css_extract": CSSExtractValueSource,
|
||||
}
|
||||
|
||||
@@ -9,9 +9,15 @@ from wled_controller.storage.database import Database
|
||||
from wled_controller.storage.utils import resolve_ref
|
||||
from wled_controller.storage.value_source import (
|
||||
AdaptiveValueSource,
|
||||
AdaptiveTimeColorValueSource,
|
||||
AnimatedColorValueSource,
|
||||
AnimatedValueSource,
|
||||
AudioValueSource,
|
||||
CSSExtractValueSource,
|
||||
DaylightValueSource,
|
||||
GradientMapValueSource,
|
||||
HAEntityValueSource,
|
||||
StaticColorValueSource,
|
||||
StaticValueSource,
|
||||
ValueSource,
|
||||
)
|
||||
@@ -56,9 +62,36 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
||||
auto_gain: Optional[bool] = None,
|
||||
use_real_time: Optional[bool] = None,
|
||||
latitude: Optional[float] = None,
|
||||
color: Optional[list] = None,
|
||||
colors: Optional[list] = None,
|
||||
easing: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
ha_source_id: Optional[str] = None,
|
||||
entity_id: Optional[str] = None,
|
||||
attribute: Optional[str] = None,
|
||||
min_ha_value: Optional[float] = None,
|
||||
max_ha_value: Optional[float] = None,
|
||||
value_source_id: Optional[str] = None,
|
||||
gradient_id: Optional[str] = None,
|
||||
color_strip_source_id: Optional[str] = None,
|
||||
led_start: Optional[int] = None,
|
||||
led_end: Optional[int] = None,
|
||||
) -> ValueSource:
|
||||
if source_type not in ("static", "animated", "audio", "adaptive_time", "adaptive_scene", "daylight"):
|
||||
_VALID = (
|
||||
"static",
|
||||
"animated",
|
||||
"audio",
|
||||
"adaptive_time",
|
||||
"adaptive_scene",
|
||||
"daylight",
|
||||
"static_color",
|
||||
"animated_color",
|
||||
"adaptive_time_color",
|
||||
"ha_entity",
|
||||
"gradient_map",
|
||||
"css_extract",
|
||||
)
|
||||
if source_type not in _VALID:
|
||||
raise ValueError(f"Invalid source type: {source_type}")
|
||||
|
||||
self._check_name_unique(name)
|
||||
@@ -70,14 +103,24 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
||||
|
||||
if source_type == "static":
|
||||
source: ValueSource = StaticValueSource(
|
||||
id=sid, name=name, source_type="static",
|
||||
created_at=now, updated_at=now, description=description, tags=common_tags,
|
||||
id=sid,
|
||||
name=name,
|
||||
source_type="static",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=common_tags,
|
||||
value=value if value is not None else 1.0,
|
||||
)
|
||||
elif source_type == "animated":
|
||||
source = AnimatedValueSource(
|
||||
id=sid, name=name, source_type="animated",
|
||||
created_at=now, updated_at=now, description=description, tags=common_tags,
|
||||
id=sid,
|
||||
name=name,
|
||||
source_type="animated",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=common_tags,
|
||||
waveform=waveform or "sine",
|
||||
speed=speed if speed is not None else 10.0,
|
||||
min_value=min_value if min_value is not None else 0.0,
|
||||
@@ -85,8 +128,13 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
||||
)
|
||||
elif source_type == "audio":
|
||||
source = AudioValueSource(
|
||||
id=sid, name=name, source_type="audio",
|
||||
created_at=now, updated_at=now, description=description, tags=common_tags,
|
||||
id=sid,
|
||||
name=name,
|
||||
source_type="audio",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=common_tags,
|
||||
audio_source_id=audio_source_id or "",
|
||||
mode=mode or "rms",
|
||||
sensitivity=sensitivity if sensitivity is not None else 1.0,
|
||||
@@ -100,16 +148,26 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
||||
if len(schedule_data) < 2:
|
||||
raise ValueError("Time of day schedule requires at least 2 points")
|
||||
source = AdaptiveValueSource(
|
||||
id=sid, name=name, source_type="adaptive_time",
|
||||
created_at=now, updated_at=now, description=description, tags=common_tags,
|
||||
id=sid,
|
||||
name=name,
|
||||
source_type="adaptive_time",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=common_tags,
|
||||
schedule=schedule_data,
|
||||
min_value=min_value if min_value is not None else 0.0,
|
||||
max_value=max_value if max_value is not None else 1.0,
|
||||
)
|
||||
elif source_type == "adaptive_scene":
|
||||
source = AdaptiveValueSource(
|
||||
id=sid, name=name, source_type="adaptive_scene",
|
||||
created_at=now, updated_at=now, description=description, tags=common_tags,
|
||||
id=sid,
|
||||
name=name,
|
||||
source_type="adaptive_scene",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=common_tags,
|
||||
picture_source_id=picture_source_id or "",
|
||||
scene_behavior=scene_behavior or "complement",
|
||||
sensitivity=sensitivity if sensitivity is not None else 1.0,
|
||||
@@ -119,14 +177,111 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
||||
)
|
||||
elif source_type == "daylight":
|
||||
source = DaylightValueSource(
|
||||
id=sid, name=name, source_type="daylight",
|
||||
created_at=now, updated_at=now, description=description, tags=common_tags,
|
||||
id=sid,
|
||||
name=name,
|
||||
source_type="daylight",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=common_tags,
|
||||
speed=speed if speed is not None else 1.0,
|
||||
use_real_time=bool(use_real_time) if use_real_time is not None else False,
|
||||
latitude=latitude if latitude is not None else 50.0,
|
||||
min_value=min_value if min_value is not None else 0.0,
|
||||
max_value=max_value if max_value is not None else 1.0,
|
||||
)
|
||||
elif source_type == "static_color":
|
||||
source = StaticColorValueSource(
|
||||
id=sid,
|
||||
name=name,
|
||||
source_type="static_color",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=common_tags,
|
||||
color=color if isinstance(color, list) and len(color) == 3 else [255, 255, 255],
|
||||
)
|
||||
elif source_type == "animated_color":
|
||||
source = AnimatedColorValueSource(
|
||||
id=sid,
|
||||
name=name,
|
||||
source_type="animated_color",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=common_tags,
|
||||
colors=(
|
||||
colors
|
||||
if isinstance(colors, list) and len(colors) >= 2
|
||||
else [[255, 0, 0], [0, 255, 0], [0, 0, 255]]
|
||||
),
|
||||
speed=speed if speed is not None else 10.0,
|
||||
easing=easing or "linear",
|
||||
)
|
||||
elif source_type == "adaptive_time_color":
|
||||
schedule_data = schedule or []
|
||||
if len(schedule_data) < 2:
|
||||
raise ValueError("Color schedule requires at least 2 points")
|
||||
source = AdaptiveTimeColorValueSource(
|
||||
id=sid,
|
||||
name=name,
|
||||
source_type="adaptive_time_color",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=common_tags,
|
||||
schedule=schedule_data,
|
||||
)
|
||||
elif source_type == "ha_entity":
|
||||
if not ha_source_id:
|
||||
raise ValueError("HA source ID is required for ha_entity type")
|
||||
if not entity_id:
|
||||
raise ValueError("Entity ID is required for ha_entity type")
|
||||
source = HAEntityValueSource(
|
||||
id=sid,
|
||||
name=name,
|
||||
source_type="ha_entity",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=common_tags,
|
||||
ha_source_id=ha_source_id,
|
||||
entity_id=entity_id,
|
||||
attribute=attribute or "",
|
||||
min_ha_value=min_ha_value if min_ha_value is not None else 0.0,
|
||||
max_ha_value=max_ha_value if max_ha_value is not None else 100.0,
|
||||
smoothing=smoothing if smoothing is not None else 0.0,
|
||||
)
|
||||
elif source_type == "gradient_map":
|
||||
if not value_source_id:
|
||||
raise ValueError("Value source ID is required for gradient_map type")
|
||||
source = GradientMapValueSource(
|
||||
id=sid,
|
||||
name=name,
|
||||
source_type="gradient_map",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=common_tags,
|
||||
value_source_id=value_source_id,
|
||||
gradient_id=gradient_id or "",
|
||||
easing=easing or "linear",
|
||||
)
|
||||
elif source_type == "css_extract":
|
||||
if not color_strip_source_id:
|
||||
raise ValueError("Color strip source ID is required for css_extract type")
|
||||
source = CSSExtractValueSource(
|
||||
id=sid,
|
||||
name=name,
|
||||
source_type="css_extract",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=common_tags,
|
||||
color_strip_source_id=color_strip_source_id,
|
||||
led_start=led_start if led_start is not None else 0,
|
||||
led_end=led_end if led_end is not None else -1,
|
||||
)
|
||||
|
||||
self._items[sid] = source
|
||||
self._save_item(sid, source)
|
||||
@@ -154,7 +309,20 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
||||
auto_gain: Optional[bool] = None,
|
||||
use_real_time: Optional[bool] = None,
|
||||
latitude: Optional[float] = None,
|
||||
color: Optional[list] = None,
|
||||
colors: Optional[list] = None,
|
||||
easing: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
ha_source_id: Optional[str] = None,
|
||||
entity_id: Optional[str] = None,
|
||||
attribute: Optional[str] = None,
|
||||
min_ha_value: Optional[float] = None,
|
||||
max_ha_value: Optional[float] = None,
|
||||
value_source_id: Optional[str] = None,
|
||||
gradient_id: Optional[str] = None,
|
||||
color_strip_source_id: Optional[str] = None,
|
||||
led_start: Optional[int] = None,
|
||||
led_end: Optional[int] = None,
|
||||
) -> ValueSource:
|
||||
source = self.get(source_id)
|
||||
|
||||
@@ -222,6 +390,50 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
||||
source.min_value = min_value
|
||||
if max_value is not None:
|
||||
source.max_value = max_value
|
||||
elif isinstance(source, StaticColorValueSource):
|
||||
if color is not None and isinstance(color, list) and len(color) == 3:
|
||||
source.color = color
|
||||
elif isinstance(source, AnimatedColorValueSource):
|
||||
if colors is not None and isinstance(colors, list):
|
||||
source.colors = colors
|
||||
if speed is not None:
|
||||
source.speed = speed
|
||||
if easing is not None:
|
||||
source.easing = easing
|
||||
elif isinstance(source, AdaptiveTimeColorValueSource):
|
||||
if schedule is not None:
|
||||
if len(schedule) < 2:
|
||||
raise ValueError("Color schedule requires at least 2 points")
|
||||
source.schedule = schedule
|
||||
elif isinstance(source, HAEntityValueSource):
|
||||
if ha_source_id is not None:
|
||||
source.ha_source_id = resolve_ref(ha_source_id, source.ha_source_id)
|
||||
if entity_id is not None:
|
||||
source.entity_id = entity_id
|
||||
if attribute is not None:
|
||||
source.attribute = attribute
|
||||
if min_ha_value is not None:
|
||||
source.min_ha_value = min_ha_value
|
||||
if max_ha_value is not None:
|
||||
source.max_ha_value = max_ha_value
|
||||
if smoothing is not None:
|
||||
source.smoothing = smoothing
|
||||
elif isinstance(source, GradientMapValueSource):
|
||||
if value_source_id is not None:
|
||||
source.value_source_id = resolve_ref(value_source_id, source.value_source_id)
|
||||
if gradient_id is not None:
|
||||
source.gradient_id = resolve_ref(gradient_id, source.gradient_id)
|
||||
if easing is not None:
|
||||
source.easing = easing
|
||||
elif isinstance(source, CSSExtractValueSource):
|
||||
if color_strip_source_id is not None:
|
||||
source.color_strip_source_id = resolve_ref(
|
||||
color_strip_source_id, source.color_strip_source_id
|
||||
)
|
||||
if led_start is not None:
|
||||
source.led_start = led_start
|
||||
if led_end is not None:
|
||||
source.led_end = led_end
|
||||
|
||||
source.updated_at = datetime.now(timezone.utc)
|
||||
self._save_item(source_id, source)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="calibration-device-id">
|
||||
<input type="hidden" id="calibration-css-id">
|
||||
<input type="hidden" id="calibration-css-source-type">
|
||||
<!-- Device picker shown in CSS calibration mode for edge testing -->
|
||||
<div id="calibration-css-test-group" class="form-group" style="display:none; margin-bottom: 12px; padding: 0 4px;">
|
||||
<div class="label-row">
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.static_color.hint">The solid color that will be sent to all LEDs on the strip.</small>
|
||||
<input type="color" id="css-editor-color" value="#ffffff">
|
||||
<div id="css-editor-color-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.color.hint">Head color for the meteor effect.</small>
|
||||
<input type="color" id="css-editor-effect-color" value="#ff5000" oninput="updateEffectPreview()">
|
||||
<div id="css-editor-effect-color-container"></div>
|
||||
</div>
|
||||
|
||||
<div id="css-editor-effect-intensity-group" class="form-group">
|
||||
@@ -306,7 +306,7 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.audio.color.hint">Low-level color for VU meter bar.</small>
|
||||
<input type="color" id="css-editor-audio-color" value="#00ff00">
|
||||
<div id="css-editor-audio-color-container"></div>
|
||||
</div>
|
||||
|
||||
<div id="css-editor-audio-color-peak-group" class="form-group" style="display:none">
|
||||
@@ -315,7 +315,7 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.audio.color_peak.hint">High-level color at the top of the VU meter bar.</small>
|
||||
<input type="color" id="css-editor-audio-color-peak" value="#ff0000">
|
||||
<div id="css-editor-audio-color-peak-container"></div>
|
||||
</div>
|
||||
|
||||
<div id="css-editor-audio-mirror-group" class="form-group" style="display:none">
|
||||
@@ -339,7 +339,7 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.api_input.fallback_color.hint">Color to display when no data has been received within the timeout period.</small>
|
||||
<input type="color" id="css-editor-api-input-fallback-color" value="#000000">
|
||||
<div id="css-editor-api-input-fallback-color-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -422,7 +422,7 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.default_color.hint">Color used when the notification has no app-specific color mapping.</small>
|
||||
<input type="color" id="css-editor-notification-default-color" value="#ffffff">
|
||||
<div id="css-editor-notification-default-color-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -468,15 +468,13 @@
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-notification-volume">
|
||||
<label>
|
||||
<span data-i18n="color_strip.notification.sound.volume">Volume:</span>
|
||||
<span id="css-editor-notification-volume-val">100%</span>
|
||||
</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.sound.volume.hint">Global volume for notification sounds (0–100%).</small>
|
||||
<input type="range" id="css-editor-notification-volume" min="0" max="100" step="5" value="100"
|
||||
oninput="document.getElementById('css-editor-notification-volume-val').textContent = this.value + '%'">
|
||||
<div id="css-editor-notification-volume-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -556,7 +554,7 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.color.hint">The warm base color of the candle flame. Default is a natural warm amber.</small>
|
||||
<input type="color" id="css-editor-candlelight-color" value="#ff9329">
|
||||
<div id="css-editor-candlelight-color-container"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
|
||||
@@ -60,14 +60,14 @@
|
||||
<div id="ha-light-editor-transition-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Brightness Value Source -->
|
||||
<!-- Brightness -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="ha-light-editor-brightness-vs" data-i18n="targets.brightness_vs">Brightness Source:</label>
|
||||
<label data-i18n="targets.brightness">Brightness:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<select id="ha-light-editor-brightness-vs">
|
||||
<option value="">None</option>
|
||||
</select>
|
||||
<small class="input-hint" style="display:none" data-i18n="targets.brightness.hint">Output brightness multiplier (0–1). Can be bound to a value source for dynamic control.</small>
|
||||
<div id="ha-light-editor-brightness-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Color Tolerance -->
|
||||
|
||||
@@ -36,13 +36,11 @@
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="target-editor-brightness-vs" data-i18n="targets.brightness_vs">Brightness Source:</label>
|
||||
<label data-i18n="targets.brightness">Brightness:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="targets.brightness_vs.hint">Optional value source that dynamically controls brightness each frame (overrides device brightness)</small>
|
||||
<select id="target-editor-brightness-vs">
|
||||
<option value="" data-i18n="targets.brightness_vs.none">None (device brightness)</option>
|
||||
</select>
|
||||
<small class="input-hint" style="display:none" data-i18n="targets.brightness.hint">Output brightness multiplier (0–1). Can be bound to a value source for dynamic control.</small>
|
||||
<div id="target-editor-brightness-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="target-editor-fps-group">
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
<option value="adaptive_time" data-i18n="value_source.type.adaptive_time">Adaptive (Time of Day)</option>
|
||||
<option value="adaptive_scene" data-i18n="value_source.type.adaptive_scene">Adaptive (Scene)</option>
|
||||
<option value="daylight" data-i18n="value_source.type.daylight">Daylight Cycle</option>
|
||||
<option value="static_color" data-i18n="value_source.type.static_color">Static Color</option>
|
||||
<option value="animated_color" data-i18n="value_source.type.animated_color">Animated Color</option>
|
||||
<option value="adaptive_time_color" data-i18n="value_source.type.adaptive_time_color">Time Color</option>
|
||||
<option value="ha_entity" data-i18n="value_source.type.ha_entity">HA Entity</option>
|
||||
<option value="gradient_map" data-i18n="value_source.type.gradient_map">Gradient Map</option>
|
||||
<option value="css_extract" data-i18n="value_source.type.css_extract">Strip Extract</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -274,6 +280,181 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Static Color fields -->
|
||||
<div id="value-source-static-color-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="value_source.static_color.color">Color:</label>
|
||||
</div>
|
||||
<input type="color" id="value-source-static-color" value="#ffffff">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Animated Color fields -->
|
||||
<div id="value-source-animated-color-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="value_source.animated_color.colors">Colors:</label>
|
||||
</div>
|
||||
<div id="value-source-animated-color-list"></div>
|
||||
<button type="button" class="btn btn-sm" onclick="addAnimatedColor()">+ Add Color</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-animated-color-speed">
|
||||
<span data-i18n="value_source.animated_color.speed">Speed (cpm):</span>
|
||||
<span id="value-source-animated-color-speed-val">10.0</span>
|
||||
</label>
|
||||
</div>
|
||||
<input type="range" id="value-source-animated-color-speed" min="0.1" max="120" step="0.1" value="10.0"
|
||||
oninput="document.getElementById('value-source-animated-color-speed-val').textContent = parseFloat(this.value).toFixed(1)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-animated-color-easing" data-i18n="value_source.animated_color.easing">Easing:</label>
|
||||
</div>
|
||||
<select id="value-source-animated-color-easing">
|
||||
<option value="linear">Linear</option>
|
||||
<option value="step">Step</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Adaptive Time Color fields -->
|
||||
<div id="value-source-adaptive-time-color-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="value_source.adaptive_time_color.schedule">Color Schedule:</label>
|
||||
</div>
|
||||
<div id="value-source-color-schedule-list"></div>
|
||||
<button type="button" class="btn btn-sm" onclick="addColorSchedulePoint()">+ Add Point</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HA Entity fields -->
|
||||
<div id="value-source-ha-entity-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-ha-source" data-i18n="value_source.ha_source">HA Connection:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.ha_source.hint">Home Assistant connection to read entities from</small>
|
||||
<select id="value-source-ha-source">
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-entity-id" data-i18n="value_source.entity_id">Entity:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.entity_id.hint">HA entity ID (e.g. sensor.temperature)</small>
|
||||
<select id="value-source-entity-id">
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-attribute" data-i18n="value_source.attribute">Attribute (optional):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.attribute.hint">Read a specific attribute instead of the entity state</small>
|
||||
<input type="text" id="value-source-attribute" placeholder="">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-min-ha-value"><span data-i18n="value_source.min_ha_value">Min HA Value:</span> <span id="value-source-min-ha-value-display">0</span></label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.min_ha_value.hint">Raw HA value that maps to 0% output</small>
|
||||
<input type="number" id="value-source-min-ha-value" step="any" value="0">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-max-ha-value"><span data-i18n="value_source.max_ha_value">Max HA Value:</span> <span id="value-source-max-ha-value-display">100</span></label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.max_ha_value.hint">Raw HA value that maps to 100% output</small>
|
||||
<input type="number" id="value-source-max-ha-value" step="any" value="100">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-ha-smoothing"><span data-i18n="value_source.smoothing">Smoothing:</span> <span id="value-source-ha-smoothing-display">0</span></label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.smoothing.hint">Temporal smoothing (0 = instant, 1 = very smooth)</small>
|
||||
<input type="range" id="value-source-ha-smoothing" min="0" max="1" step="0.05" value="0"
|
||||
oninput="document.getElementById('value-source-ha-smoothing-display').textContent = this.value">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gradient Map fields -->
|
||||
<div id="value-source-gradient-map-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-gradient-input" data-i18n="value_source.input_source">Input Value Source:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.input_source.hint">Float value source (0-1) to map through the gradient</small>
|
||||
<select id="value-source-gradient-input">
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-gradient-id" data-i18n="value_source.gradient_stops">Gradient:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.gradient_stops.hint">Select a gradient preset to map through</small>
|
||||
<select id="value-source-gradient-id">
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-gradient-easing" data-i18n="value_source.easing">Interpolation:</label>
|
||||
</div>
|
||||
<select id="value-source-gradient-easing">
|
||||
<option value="linear">Linear</option>
|
||||
<option value="step">Step</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSS Extract fields -->
|
||||
<div id="value-source-css-extract-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-css-source" data-i18n="value_source.css_source">Color Strip Source:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.css_source.hint">Color strip source to extract color from</small>
|
||||
<select id="value-source-css-source">
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-led-start"><span data-i18n="value_source.led_start">LED Start:</span></label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.led_start.hint">First LED in the range (0-based)</small>
|
||||
<input type="number" id="value-source-led-start" min="0" step="1" value="0">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-led-end"><span data-i18n="value_source.led_end">LED End:</span></label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.led_end.hint">Last LED in the range (-1 = whole strip)</small>
|
||||
<input type="number" id="value-source-led-end" min="-1" step="1" value="-1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared adaptive output range (shown for adaptive and daylight types) -->
|
||||
<div id="value-source-adaptive-range-section" style="display:none">
|
||||
<div class="form-group">
|
||||
|
||||
@@ -4,19 +4,21 @@ Tests creating, listing, updating, cloning, and deleting color strip sources.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
class TestColorStripSourceLifecycle:
|
||||
"""A user manages color strip sources for LED effects."""
|
||||
|
||||
def test_static_and_gradient_crud(self, client):
|
||||
# 1. Create a static color strip source
|
||||
resp = client.post("/api/v1/color-strip-sources", json={
|
||||
"name": "Red Static",
|
||||
"source_type": "static",
|
||||
"color": [255, 0, 0],
|
||||
"led_count": 60,
|
||||
"tags": ["e2e", "static"],
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/v1/color-strip-sources",
|
||||
json={
|
||||
"name": "Red Static",
|
||||
"source_type": "static",
|
||||
"color": [255, 0, 0],
|
||||
"led_count": 60,
|
||||
"tags": ["e2e", "static"],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, f"Create static failed: {resp.text}"
|
||||
static = resp.json()
|
||||
static_id = static["id"]
|
||||
@@ -25,15 +27,18 @@ class TestColorStripSourceLifecycle:
|
||||
assert static["color"] == [255, 0, 0]
|
||||
|
||||
# 2. Create a gradient color strip source
|
||||
resp = client.post("/api/v1/color-strip-sources", json={
|
||||
"name": "Blue-Green Gradient",
|
||||
"source_type": "gradient",
|
||||
"stops": [
|
||||
{"position": 0.0, "color": [0, 0, 255]},
|
||||
{"position": 1.0, "color": [0, 255, 0]},
|
||||
],
|
||||
"led_count": 60,
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/v1/color-strip-sources",
|
||||
json={
|
||||
"name": "Blue-Green Gradient",
|
||||
"source_type": "gradient",
|
||||
"stops": [
|
||||
{"position": 0.0, "color": [0, 0, 255]},
|
||||
{"position": 1.0, "color": [0, 255, 0]},
|
||||
],
|
||||
"led_count": 60,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, f"Create gradient failed: {resp.text}"
|
||||
gradient = resp.json()
|
||||
gradient_id = gradient["id"]
|
||||
@@ -53,7 +58,7 @@ class TestColorStripSourceLifecycle:
|
||||
# 4. Update the static source -- change color
|
||||
resp = client.put(
|
||||
f"/api/v1/color-strip-sources/{static_id}",
|
||||
json={"color": [0, 255, 0]},
|
||||
json={"source_type": "static", "color": [0, 255, 0]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["color"] == [0, 255, 0]
|
||||
@@ -64,12 +69,15 @@ class TestColorStripSourceLifecycle:
|
||||
assert resp.json()["color"] == [0, 255, 0]
|
||||
|
||||
# 6. Clone by creating another source with same data, different name
|
||||
resp = client.post("/api/v1/color-strip-sources", json={
|
||||
"name": "Cloned Static",
|
||||
"source_type": "static",
|
||||
"color": [0, 255, 0],
|
||||
"led_count": 60,
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/v1/color-strip-sources",
|
||||
json={
|
||||
"name": "Cloned Static",
|
||||
"source_type": "static",
|
||||
"color": [0, 255, 0],
|
||||
"led_count": 60,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
clone_id = resp.json()["id"]
|
||||
assert clone_id != static_id
|
||||
@@ -87,17 +95,20 @@ class TestColorStripSourceLifecycle:
|
||||
|
||||
def test_update_name(self, client):
|
||||
"""Renaming a color strip source persists."""
|
||||
resp = client.post("/api/v1/color-strip-sources", json={
|
||||
"name": "Original Name",
|
||||
"source_type": "static",
|
||||
"color": [100, 100, 100],
|
||||
"led_count": 10,
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/v1/color-strip-sources",
|
||||
json={
|
||||
"name": "Original Name",
|
||||
"source_type": "static",
|
||||
"color": [100, 100, 100],
|
||||
"led_count": 10,
|
||||
},
|
||||
)
|
||||
source_id = resp.json()["id"]
|
||||
|
||||
resp = client.put(
|
||||
f"/api/v1/color-strip-sources/{source_id}",
|
||||
json={"name": "New Name"},
|
||||
json={"source_type": "static", "name": "New Name"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "New Name"
|
||||
@@ -126,12 +137,15 @@ class TestColorStripSourceLifecycle:
|
||||
|
||||
def test_color_cycle_source(self, client):
|
||||
"""Color cycle sources store and return their color list."""
|
||||
resp = client.post("/api/v1/color-strip-sources", json={
|
||||
"name": "Rainbow Cycle",
|
||||
"source_type": "color_cycle",
|
||||
"colors": [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
|
||||
"led_count": 30,
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/v1/color-strip-sources",
|
||||
json={
|
||||
"name": "Rainbow Cycle",
|
||||
"source_type": "color_cycle",
|
||||
"colors": [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
|
||||
"led_count": 30,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["source_type"] == "color_cycle"
|
||||
@@ -139,14 +153,17 @@ class TestColorStripSourceLifecycle:
|
||||
|
||||
def test_effect_source(self, client):
|
||||
"""Effect sources store their effect parameters."""
|
||||
resp = client.post("/api/v1/color-strip-sources", json={
|
||||
"name": "Fire Effect",
|
||||
"source_type": "effect",
|
||||
"effect_type": "fire",
|
||||
"palette": "fire",
|
||||
"intensity": 1.5,
|
||||
"led_count": 60,
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/v1/color-strip-sources",
|
||||
json={
|
||||
"name": "Fire Effect",
|
||||
"source_type": "effect",
|
||||
"effect_type": "fire",
|
||||
"palette": "fire",
|
||||
"intensity": 1.5,
|
||||
"led_count": 60,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["source_type"] == "effect"
|
||||
|
||||
@@ -5,18 +5,20 @@ create device -> create target -> list -> update -> delete target -> cleanup dev
|
||||
"""
|
||||
|
||||
|
||||
|
||||
class TestOutputTargetLifecycle:
|
||||
"""A user wires up an output target to a device."""
|
||||
|
||||
def _create_device(self, client) -> str:
|
||||
"""Helper: create a mock device and return its ID."""
|
||||
resp = client.post("/api/v1/devices", json={
|
||||
"name": "Target Test Device",
|
||||
"url": "mock://target-test",
|
||||
"device_type": "mock",
|
||||
"led_count": 60,
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/v1/devices",
|
||||
json={
|
||||
"name": "Target Test Device",
|
||||
"url": "mock://target-test",
|
||||
"device_type": "mock",
|
||||
"led_count": 60,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["id"]
|
||||
|
||||
@@ -44,7 +46,7 @@ class TestOutputTargetLifecycle:
|
||||
assert target["name"] == "E2E Test Target"
|
||||
assert target["device_id"] == device_id
|
||||
assert target["target_type"] == "led"
|
||||
assert target["fps"] == 30
|
||||
assert target["fps"] == 30.0
|
||||
assert target["protocol"] == "ddp"
|
||||
|
||||
# 3. List targets -- should contain the new target
|
||||
@@ -62,12 +64,12 @@ class TestOutputTargetLifecycle:
|
||||
# 5. Update the target -- change name and fps
|
||||
resp = client.put(
|
||||
f"/api/v1/output-targets/{target_id}",
|
||||
json={"name": "Updated Target", "fps": 60},
|
||||
json={"target_type": "led", "name": "Updated Target", "fps": 60},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
updated = resp.json()
|
||||
assert updated["name"] == "Updated Target"
|
||||
assert updated["fps"] == 60
|
||||
assert updated["fps"] == 60.0
|
||||
|
||||
# 6. Verify update via GET
|
||||
resp = client.get(f"/api/v1/output-targets/{target_id}")
|
||||
@@ -90,11 +92,14 @@ class TestOutputTargetLifecycle:
|
||||
"""Deleting a device that has a target should return 409."""
|
||||
device_id = self._create_device(client)
|
||||
|
||||
resp = client.post("/api/v1/output-targets", json={
|
||||
"name": "Blocking Target",
|
||||
"target_type": "led",
|
||||
"device_id": device_id,
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/v1/output-targets",
|
||||
json={
|
||||
"name": "Blocking Target",
|
||||
"target_type": "led",
|
||||
"device_id": device_id,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
target_id = resp.json()["id"]
|
||||
|
||||
@@ -111,11 +116,14 @@ class TestOutputTargetLifecycle:
|
||||
|
||||
def test_create_target_with_invalid_device_returns_422(self, client):
|
||||
"""Creating a target with a non-existent device_id returns 422."""
|
||||
resp = client.post("/api/v1/output-targets", json={
|
||||
"name": "Orphan Target",
|
||||
"target_type": "led",
|
||||
"device_id": "nonexistent_device",
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/v1/output-targets",
|
||||
json={
|
||||
"name": "Orphan Target",
|
||||
"target_type": "led",
|
||||
"device_id": "nonexistent_device",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_get_nonexistent_target_returns_404(self, client):
|
||||
|
||||
Reference in New Issue
Block a user