adaptive-lighting/custom_components/adaptive_lighting/number.py
Ahmad Tawakol 3b1b84e7e8 Add a per-switch intensity dial (number entity)
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>
2026-09-07 14:15:59 -03:00

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)