Add adaptive brightness value source with time-of-day and scene modes

New "adaptive" value source type that automatically adjusts brightness
based on external conditions. Two sub-modes: time-of-day (schedule-based
interpolation with midnight wrap) and scene brightness (frame luminance
analysis via numpy BT.601 subsampling with EMA smoothing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 15:14:30 +03:00
parent 48651f0a4e
commit d339dd3f90
11 changed files with 643 additions and 19 deletions

View File

@@ -1,15 +1,16 @@
"""Value source data model with inheritance-based source types.
A ValueSource produces a scalar float (0.01.0) that can drive target
parameters like brightness. Three types:
parameters like brightness. Four types:
StaticValueSource — constant float value
AnimatedValueSource — periodic waveform (sine, triangle, square, sawtooth)
AudioValueSource — audio-reactive scalar (RMS, peak, beat detection)
AdaptiveValueSource — adapts to external conditions (time of day, scene brightness)
"""
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from typing import List, Optional
@dataclass
@@ -42,6 +43,10 @@ class ValueSource:
"mode": None,
"sensitivity": None,
"smoothing": None,
"adaptive_mode": None,
"schedule": None,
"picture_source_id": None,
"scene_behavior": None,
}
@staticmethod
@@ -87,6 +92,20 @@ class ValueSource:
smoothing=float(data.get("smoothing") or 0.3),
)
if source_type == "adaptive":
return AdaptiveValueSource(
id=sid, name=name, source_type="adaptive",
created_at=created_at, updated_at=updated_at, description=description,
adaptive_mode=data.get("adaptive_mode") or "time_of_day",
schedule=data.get("schedule") or [],
picture_source_id=data.get("picture_source_id") or "",
scene_behavior=data.get("scene_behavior") or "complement",
sensitivity=float(data.get("sensitivity") or 1.0),
smoothing=float(data.get("smoothing") or 0.3),
min_value=float(data.get("min_value") or 0.0),
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0,
)
# Default: "static" type
return StaticValueSource(
id=sid, name=name, source_type="static",
@@ -152,3 +171,34 @@ class AudioValueSource(ValueSource):
d["sensitivity"] = self.sensitivity
d["smoothing"] = self.smoothing
return d
@dataclass
class AdaptiveValueSource(ValueSource):
"""Value source that adapts to external conditions.
Two sub-modes:
time_of_day — interpolates brightness along a 24-hour schedule
scene — derives brightness from a picture source's frame luminance
"""
adaptive_mode: str = "time_of_day" # "time_of_day" | "scene"
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
def to_dict(self) -> dict:
d = super().to_dict()
d["adaptive_mode"] = self.adaptive_mode
d["schedule"] = self.schedule
d["picture_source_id"] = self.picture_source_id
d["scene_behavior"] = self.scene_behavior
d["sensitivity"] = self.sensitivity
d["smoothing"] = self.smoothing
d["min_value"] = self.min_value
d["max_value"] = self.max_value
return d

View File

@@ -7,6 +7,7 @@ from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.storage.value_source import (
AdaptiveValueSource,
AnimatedValueSource,
AudioValueSource,
StaticValueSource,
@@ -101,11 +102,15 @@ class ValueSourceStore:
sensitivity: Optional[float] = None,
smoothing: Optional[float] = None,
description: Optional[str] = None,
adaptive_mode: Optional[str] = None,
schedule: Optional[list] = None,
picture_source_id: Optional[str] = None,
scene_behavior: Optional[str] = None,
) -> ValueSource:
if not name or not name.strip():
raise ValueError("Name is required")
if source_type not in ("static", "animated", "audio"):
if source_type not in ("static", "animated", "audio", "adaptive"):
raise ValueError(f"Invalid source type: {source_type}")
for source in self._sources.values():
@@ -139,6 +144,23 @@ class ValueSourceStore:
sensitivity=sensitivity if sensitivity is not None else 1.0,
smoothing=smoothing if smoothing is not None else 0.3,
)
elif source_type == "adaptive":
am = adaptive_mode or "time_of_day"
schedule_data = schedule or []
if am == "time_of_day" and len(schedule_data) < 2:
raise ValueError("Time of day schedule requires at least 2 points")
source = AdaptiveValueSource(
id=sid, name=name, source_type="adaptive",
created_at=now, updated_at=now, description=description,
adaptive_mode=am,
schedule=schedule_data,
picture_source_id=picture_source_id or "",
scene_behavior=scene_behavior or "complement",
sensitivity=sensitivity if sensitivity is not None else 1.0,
smoothing=smoothing if smoothing is not None else 0.3,
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,
)
self._sources[sid] = source
self._save()
@@ -160,6 +182,10 @@ class ValueSourceStore:
sensitivity: Optional[float] = None,
smoothing: Optional[float] = None,
description: Optional[str] = None,
adaptive_mode: Optional[str] = None,
schedule: Optional[list] = None,
picture_source_id: Optional[str] = None,
scene_behavior: Optional[str] = None,
) -> ValueSource:
if source_id not in self._sources:
raise ValueError(f"Value source not found: {source_id}")
@@ -196,6 +222,25 @@ class ValueSourceStore:
source.sensitivity = sensitivity
if smoothing is not None:
source.smoothing = smoothing
elif isinstance(source, AdaptiveValueSource):
if adaptive_mode is not None:
source.adaptive_mode = adaptive_mode
if schedule is not None:
if source.adaptive_mode == "time_of_day" and len(schedule) < 2:
raise ValueError("Time of day schedule requires at least 2 points")
source.schedule = schedule
if picture_source_id is not None:
source.picture_source_id = picture_source_id
if scene_behavior is not None:
source.scene_behavior = scene_behavior
if sensitivity is not None:
source.sensitivity = sensitivity
if smoothing is not None:
source.smoothing = smoothing
if min_value is not None:
source.min_value = min_value
if max_value is not None:
source.max_value = max_value
source.updated_at = datetime.utcnow()
self._save()