mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-17 09:14:04 +02:00
Adds one `number.adaptive_lighting_intensity_<name>` entity per
configuration, alongside the Sleep Mode / Adapt Brightness / Adapt Color
switches on the same device. It is a 0-100% slider that interpolates the
adaptive result towards the switch's floor:
output = floor_value + (adaptive_value - floor_value) * intensity / 100
100 short-circuits and returns the adaptive value untouched, so it is the
default and existing configurations behave exactly as before. 0 returns
the floor. In between the light stays fully adaptive -- the sun still
moves it at every setting, so it is a scaled curve rather than a frozen
level, which is what distinguishes this from simply scaling the
brightness band.
The floor is set by a new per-switch `intensity_floor` option: the
switch's sleep settings (`sleep_brightness`/`sleep_color_temp`) by
default, or its `min_brightness`/`min_color_temp`.
`transition_until_sleep` forces the sleep floor whatever `intensity_floor`
says. With that option on, the adaptive color temperature after sunset
descends below `min_color_temp` towards `sleep_color_temp`, so a
`min_color_temp` floor would sit above the adaptive value and turning the
dial down would make the light cooler.
Documented in the README's features and a new Intensity section, and on
the docs site as advanced/intensity.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
147 lines
5.7 KiB
Python
147 lines
5.7 KiB
Python
"""A per-switch intensity dial for Adaptive Lighting.
|
|
|
|
Adds one number entity per configuration, sitting alongside the Sleep Mode /
|
|
Adapt Brightness / Adapt Color switches on the same device:
|
|
|
|
number.adaptive_lighting_intensity_<name>
|
|
|
|
100% is the normal adaptive behaviour. 0% is the switch's floor, set by the
|
|
``intensity_floor`` option: its sleep settings (``sleep_brightness`` and
|
|
``sleep_color_temp``) by default, or its ``min_brightness``/``min_color_temp``.
|
|
Anything in between is a straight interpolation between the two, recomputed
|
|
continuously, so the sun still moves the light at every setting -- it is a
|
|
scaled adaptive curve, not a frozen snapshot.
|
|
|
|
The value survives restarts via RestoreEntity, and is re-applied to the switch
|
|
whenever it changes so the lights follow immediately instead of waiting for the
|
|
next adaptation interval.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from homeassistant.components.number import NumberEntity, NumberMode
|
|
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
|
from homeassistant.const import PERCENTAGE
|
|
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
|
from homeassistant.helpers.restore_state import RestoreEntity
|
|
from homeassistant.util import slugify
|
|
|
|
from .const import (
|
|
CONF_NAME,
|
|
DEFAULT_INTENSITY,
|
|
DOMAIN,
|
|
ICON_INTENSITY,
|
|
INTENSITY_NUMBER,
|
|
PENDING_INTENSITY,
|
|
)
|
|
from .switch import validate
|
|
|
|
if TYPE_CHECKING:
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
config_entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""Set up the intensity number for one Adaptive Lighting configuration."""
|
|
data = hass.data[DOMAIN]
|
|
number = AdaptiveIntensityNumber(hass, config_entry)
|
|
data[config_entry.entry_id][INTENSITY_NUMBER] = number
|
|
async_add_entities([number], update_before_add=True)
|
|
|
|
|
|
class AdaptiveIntensityNumber(NumberEntity, RestoreEntity):
|
|
"""A 0-100% dial between the adaptive settings and the switch's floor."""
|
|
|
|
_attr_native_min_value = 0.0
|
|
_attr_native_max_value = 100.0
|
|
_attr_native_step = 1.0
|
|
_attr_native_unit_of_measurement = PERCENTAGE
|
|
_attr_mode = NumberMode.SLIDER
|
|
_attr_should_poll = False
|
|
# Matches the sibling switches: the device supplies "Adaptive Lighting:
|
|
# <name>" and this entity contributes only "Intensity".
|
|
_attr_has_entity_name = True
|
|
|
|
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
|
"""Initialize the intensity number."""
|
|
self.hass = hass
|
|
self._config_entry = config_entry
|
|
config = validate(config_entry)
|
|
self._config_name = config[CONF_NAME]
|
|
self._which = "Intensity"
|
|
self._attr_unique_id = f"{self._config_name}_{slugify(self._which)}"
|
|
self._attr_name = self._which
|
|
self._attr_icon = ICON_INTENSITY
|
|
self._value: float = DEFAULT_INTENSITY
|
|
|
|
@property
|
|
def native_value(self) -> float:
|
|
"""Return the current intensity."""
|
|
return self._value
|
|
|
|
@property
|
|
def device_info(self) -> DeviceInfo:
|
|
"""Group with the other entities for this configuration."""
|
|
return DeviceInfo(
|
|
identifiers={(DOMAIN, self._config_name)},
|
|
name=f"Adaptive Lighting: {self._config_name}",
|
|
entry_type=DeviceEntryType.SERVICE,
|
|
)
|
|
|
|
@property
|
|
def _switch(self) -> Any | None:
|
|
"""The AdaptiveSwitch this dial belongs to, if it is set up yet."""
|
|
entry = self.hass.data.get(DOMAIN, {}).get(self._config_entry.entry_id, {})
|
|
return entry.get(SWITCH_DOMAIN)
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
"""Restore the last value and push it to the switch."""
|
|
await super().async_added_to_hass()
|
|
last_state = await self.async_get_last_state()
|
|
if last_state is not None:
|
|
try:
|
|
self._value = float(last_state.state)
|
|
except (TypeError, ValueError):
|
|
_LOGGER.debug(
|
|
"%s: could not restore intensity from %s, using %s",
|
|
self._attr_name,
|
|
last_state.state,
|
|
DEFAULT_INTENSITY,
|
|
)
|
|
# Push even at 100 so the switch and the entity can never disagree.
|
|
# Re-adapt only if this is an actual dimmed level being restored, so a
|
|
# plain restart does not push a redundant adaptation at every switch.
|
|
await self._push(adapt=self._value != DEFAULT_INTENSITY)
|
|
|
|
async def async_set_native_value(self, value: float) -> None:
|
|
"""Set a new intensity and re-adapt the lights straight away."""
|
|
self._value = float(value)
|
|
self.async_write_ha_state()
|
|
await self._push(adapt=True)
|
|
|
|
async def _push(self, *, adapt: bool) -> None:
|
|
switch = self._switch
|
|
if switch is None:
|
|
# Home Assistant does not guarantee that the "switch" platform
|
|
# finishes before "number", so the switch may not exist yet. Leave
|
|
# the value where AdaptiveSwitch.async_added_to_hass will find it
|
|
# rather than dropping a restored dial on the floor.
|
|
entry_data = self.hass.data[DOMAIN][self._config_entry.entry_id]
|
|
entry_data[PENDING_INTENSITY] = self._value
|
|
_LOGGER.debug(
|
|
"%s: switch not set up yet, handing intensity %s over to it",
|
|
self._attr_name,
|
|
self._value,
|
|
)
|
|
return
|
|
await switch.async_set_intensity(self._value, adapt=adapt)
|