mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-18 09:44:05 +02:00
const.py:
- Drop 21 retired CONF_*/DEFAULT_* (sleep cluster, manual sun timing,
brightness curve variants, take-over-control cluster).
- Add CONF_SUNRISE_ENTITY / CONF_SUNSET_ENTITY (default
sensor.sun_next_rising / _setting).
- Add RAMP_HALF_WIDTH_SECONDS = 1800.
- Add CONFIG_ENTRY_VERSION = 2 (gate for strict version-break).
- Re-default DEFAULT_MIN_BRIGHTNESS 1→5, DEFAULT_MIN_COLOR_TEMP 2000→2200.
- Update ICON_MAIN / _BRIGHTNESS / _COLOR_TEMP to the CDiT picks.
manifest.json: bump to 2.0.0-cdit.1, pin homeassistant: 2025.1.0,
re-point codeowners/documentation/issue_tracker to the CaseyRo fork.
switch.py:
- Drop sleep_mode_switch creation in async_setup_entry; integration
now creates 3 switches per profile (master, adapt_color, adapt_brightness).
- Strip sleep-mode state machine from AdaptiveSwitch (sleep_mode_switch
attribute, sleep_transition, adapt_until_sleep, the state-change
listener, the _sleep_mode_switch_state_event_action method).
- Bridge-stub _take_over_control / _detect_non_ha_changes /
_adapt_only_on_bare_turn_on / _only_once / _auto_reset_manual_control_time
as class-level False/0 so Manager-side branches become dead code without
needing a full rewrite of AdaptiveLightingManager.
- Remove handle_set_manual_control service + its registration.
- Replace astral-driven curve init with the new SunLightSettings call
shape; add AdaptiveSwitch._today_sun_events() helper that reads the
configured sunrise/sunset entities.
- Set _attr_icon on AdaptiveSwitch.
color_and_brightness.py:
- Rewrite SunLightSettings as a pure curve-math wrapper (5 fields:
name + bounds + ramp half-width); methods take t_sunrise/t_sunset
as args, never reads HA state.
- Implement piecewise tanh ramp curve per spec R4 / design D11 in
_tanh_day_curve(). Both brightness and color-temp use the same shape.
- Drop SunEvents, sleep-mode branches, brightness_mode-switch, astral
dependency, force_rgb_color, lerp_color_hsv sleep-tinted color blending.
config_flow.py:
- Rewrite as sectioned schema (Targets / Daytime curve / Sun schedule /
Light control / Advanced / Diagnostics) via HA's section() helper.
- Native HA selectors throughout (NumberSelector, EntitySelector,
BooleanSelector). Strict typing on sun-event entity pickers.
- Conditional visibility for send_split_delay (driver:
separate_turn_on_commands).
- Extend OptionsFlowWithReload (with ImportError fallback for HA < 2025.1
dev environment); async_abort(reason="yaml_managed") for SOURCE_IMPORT.
- VERSION = CONFIG_ENTRY_VERSION.
__init__.py:
- Reject older config entries with ConfigEntryError ("recreate the entry")
per spec R8 / design D4.
- Add _remove_orphan_sleep_entities tombstone helper that scans the
entity registry for sleep-mode entities owned by this entry and
removes them on first setup. Idempotent and config_entry-scoped.
_docs_helpers.py: stub the removed DOCS_MANUAL_CONTROL / SET_MANUAL_CONTROL_SCHEMA
so the docs-generator script keeps importing.
Validation:
- ALL modules import cleanly under HA 2024.12.5 (dev env).
- openspec validate cdit-config-redesign --strict: green.
- ruff check --select=F: clean.
Remaining: groups 7 (16 tests), 8 (strings + plain-language pass), 9 (docs).
415 lines
13 KiB
Python
415 lines
13 KiB
Python
"""Config flow for Adaptive Lighting (CDiT fork).
|
|
|
|
Replaces upstream's flat 40-field options dialog with a six-section
|
|
layout (Targets / Daytime curve / Sun schedule / Light control / Advanced /
|
|
Diagnostics) using HA's `section()` helper and native selectors throughout.
|
|
See design.md decisions 1, 5, 6, 9, 14 and specs/options-flow/spec.md R1-R7.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
import voluptuous as vol
|
|
from homeassistant.config_entries import (
|
|
SOURCE_IMPORT,
|
|
ConfigEntry,
|
|
)
|
|
from homeassistant.config_entries import ConfigFlow as HAConfigFlow
|
|
|
|
try:
|
|
from homeassistant.config_entries import OptionsFlowWithReload
|
|
except ImportError: # pragma: no cover — HA < 2025.1 fallback for dev env
|
|
from homeassistant.config_entries import OptionsFlow as OptionsFlowWithReload
|
|
from homeassistant.const import CONF_NAME
|
|
from homeassistant.core import callback
|
|
from homeassistant.data_entry_flow import section
|
|
from homeassistant.helpers.selector import (
|
|
BooleanSelector,
|
|
EntitySelector,
|
|
EntitySelectorConfig,
|
|
NumberSelector,
|
|
NumberSelectorConfig,
|
|
NumberSelectorMode,
|
|
)
|
|
|
|
from .const import (
|
|
CONF_ADAPT_DELAY,
|
|
CONF_INCLUDE_CONFIG_IN_ATTRIBUTES,
|
|
CONF_INITIAL_TRANSITION,
|
|
CONF_INTERCEPT,
|
|
CONF_INTERVAL,
|
|
CONF_LIGHTS,
|
|
CONF_MAX_BRIGHTNESS,
|
|
CONF_MAX_COLOR_TEMP,
|
|
CONF_MIN_BRIGHTNESS,
|
|
CONF_MIN_COLOR_TEMP,
|
|
CONF_MULTI_LIGHT_INTERCEPT,
|
|
CONF_PREFER_RGB_COLOR,
|
|
CONF_SEND_SPLIT_DELAY,
|
|
CONF_SEPARATE_TURN_ON_COMMANDS,
|
|
CONF_SKIP_REDUNDANT_COMMANDS,
|
|
CONF_SUNRISE_ENTITY,
|
|
CONF_SUNSET_ENTITY,
|
|
CONF_TRANSITION,
|
|
CONFIG_ENTRY_VERSION,
|
|
DEFAULT_INITIAL_TRANSITION,
|
|
DEFAULT_INTERCEPT,
|
|
DEFAULT_INTERVAL,
|
|
DEFAULT_MAX_BRIGHTNESS,
|
|
DEFAULT_MAX_COLOR_TEMP,
|
|
DEFAULT_MIN_BRIGHTNESS,
|
|
DEFAULT_MIN_COLOR_TEMP,
|
|
DEFAULT_MULTI_LIGHT_INTERCEPT,
|
|
DEFAULT_PREFER_RGB_COLOR,
|
|
DEFAULT_SEND_SPLIT_DELAY,
|
|
DEFAULT_SEPARATE_TURN_ON_COMMANDS,
|
|
DEFAULT_SKIP_REDUNDANT_COMMANDS,
|
|
DEFAULT_SUNRISE_ENTITY,
|
|
DEFAULT_SUNSET_ENTITY,
|
|
DEFAULT_TRANSITION,
|
|
DOMAIN,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
# --- Section identifiers (also the translation-key roots in strings.json) ---
|
|
SECTION_TARGETS = "targets"
|
|
SECTION_DAYTIME = "daytime_curve"
|
|
SECTION_SUN = "sun_schedule"
|
|
SECTION_LIGHT_CONTROL = "light_control"
|
|
SECTION_ADVANCED = "advanced"
|
|
SECTION_DIAGNOSTICS = "diagnostics"
|
|
|
|
|
|
# --- Selector factories (consistent units, ranges, units of measurement) ---
|
|
|
|
|
|
def _brightness_selector() -> NumberSelector:
|
|
return NumberSelector(
|
|
NumberSelectorConfig(
|
|
min=1,
|
|
max=100,
|
|
step=1,
|
|
unit_of_measurement="%",
|
|
mode=NumberSelectorMode.SLIDER,
|
|
),
|
|
)
|
|
|
|
|
|
def _color_temp_selector() -> NumberSelector:
|
|
return NumberSelector(
|
|
NumberSelectorConfig(
|
|
min=1000,
|
|
max=10000,
|
|
step=100,
|
|
unit_of_measurement="K",
|
|
mode=NumberSelectorMode.BOX,
|
|
),
|
|
)
|
|
|
|
|
|
def _duration_seconds_selector(max_value: int = 3600) -> NumberSelector:
|
|
return NumberSelector(
|
|
NumberSelectorConfig(
|
|
min=0,
|
|
max=max_value,
|
|
step=1,
|
|
unit_of_measurement="s",
|
|
mode=NumberSelectorMode.BOX,
|
|
),
|
|
)
|
|
|
|
|
|
def _duration_milliseconds_selector() -> NumberSelector:
|
|
return NumberSelector(
|
|
NumberSelectorConfig(
|
|
min=0,
|
|
max=10000,
|
|
step=10,
|
|
unit_of_measurement="ms",
|
|
mode=NumberSelectorMode.BOX,
|
|
),
|
|
)
|
|
|
|
|
|
def _lights_selector() -> EntitySelector:
|
|
return EntitySelector(EntitySelectorConfig(domain="light", multiple=True))
|
|
|
|
|
|
def _sun_event_selector() -> EntitySelector:
|
|
return EntitySelector(
|
|
EntitySelectorConfig(domain="sensor", device_class="timestamp"),
|
|
)
|
|
|
|
|
|
# --- Schema builder ---
|
|
|
|
|
|
def _build_options_schema(
|
|
current: dict[str, Any],
|
|
*,
|
|
show_send_split_delay: bool,
|
|
) -> vol.Schema:
|
|
"""Build the sectioned options schema from the entry's current values."""
|
|
|
|
targets_section = section(
|
|
vol.Schema(
|
|
{
|
|
vol.Required(
|
|
CONF_LIGHTS,
|
|
default=current.get(CONF_LIGHTS, []),
|
|
): _lights_selector(),
|
|
},
|
|
),
|
|
{"collapsed": False},
|
|
)
|
|
|
|
daytime_section = section(
|
|
vol.Schema(
|
|
{
|
|
vol.Required(
|
|
CONF_MIN_BRIGHTNESS,
|
|
default=current.get(CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS),
|
|
): _brightness_selector(),
|
|
vol.Required(
|
|
CONF_MAX_BRIGHTNESS,
|
|
default=current.get(CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS),
|
|
): _brightness_selector(),
|
|
vol.Required(
|
|
CONF_MIN_COLOR_TEMP,
|
|
default=current.get(CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP),
|
|
): _color_temp_selector(),
|
|
vol.Required(
|
|
CONF_MAX_COLOR_TEMP,
|
|
default=current.get(CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP),
|
|
): _color_temp_selector(),
|
|
vol.Required(
|
|
CONF_PREFER_RGB_COLOR,
|
|
default=current.get(CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR),
|
|
): BooleanSelector(),
|
|
},
|
|
),
|
|
{"collapsed": False},
|
|
)
|
|
|
|
sun_section = section(
|
|
vol.Schema(
|
|
{
|
|
vol.Required(
|
|
CONF_SUNRISE_ENTITY,
|
|
default=current.get(CONF_SUNRISE_ENTITY, DEFAULT_SUNRISE_ENTITY),
|
|
): _sun_event_selector(),
|
|
vol.Required(
|
|
CONF_SUNSET_ENTITY,
|
|
default=current.get(CONF_SUNSET_ENTITY, DEFAULT_SUNSET_ENTITY),
|
|
): _sun_event_selector(),
|
|
},
|
|
),
|
|
{"collapsed": False},
|
|
)
|
|
|
|
light_control_section = section(
|
|
vol.Schema(
|
|
{
|
|
vol.Required(
|
|
CONF_INTERCEPT,
|
|
default=current.get(CONF_INTERCEPT, DEFAULT_INTERCEPT),
|
|
): BooleanSelector(),
|
|
vol.Required(
|
|
CONF_MULTI_LIGHT_INTERCEPT,
|
|
default=current.get(
|
|
CONF_MULTI_LIGHT_INTERCEPT,
|
|
DEFAULT_MULTI_LIGHT_INTERCEPT,
|
|
),
|
|
): BooleanSelector(),
|
|
},
|
|
),
|
|
{"collapsed": False},
|
|
)
|
|
|
|
advanced_schema: dict[Any, Any] = {
|
|
vol.Required(
|
|
CONF_INTERVAL,
|
|
default=current.get(CONF_INTERVAL, DEFAULT_INTERVAL),
|
|
): _duration_seconds_selector(),
|
|
vol.Required(
|
|
CONF_TRANSITION,
|
|
default=current.get(CONF_TRANSITION, DEFAULT_TRANSITION),
|
|
): _duration_seconds_selector(max_value=300),
|
|
vol.Required(
|
|
CONF_INITIAL_TRANSITION,
|
|
default=current.get(CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION),
|
|
): _duration_seconds_selector(max_value=300),
|
|
vol.Required(
|
|
CONF_ADAPT_DELAY,
|
|
default=current.get(CONF_ADAPT_DELAY, 0),
|
|
): _duration_seconds_selector(max_value=300),
|
|
vol.Required(
|
|
CONF_SEPARATE_TURN_ON_COMMANDS,
|
|
default=current.get(
|
|
CONF_SEPARATE_TURN_ON_COMMANDS,
|
|
DEFAULT_SEPARATE_TURN_ON_COMMANDS,
|
|
),
|
|
): BooleanSelector(),
|
|
vol.Required(
|
|
CONF_SKIP_REDUNDANT_COMMANDS,
|
|
default=current.get(
|
|
CONF_SKIP_REDUNDANT_COMMANDS,
|
|
DEFAULT_SKIP_REDUNDANT_COMMANDS,
|
|
),
|
|
): BooleanSelector(),
|
|
}
|
|
# Conditional visibility for send_split_delay: only when its driver
|
|
# (separate_turn_on_commands) is true. Spec R2.
|
|
if show_send_split_delay:
|
|
advanced_schema[
|
|
vol.Required(
|
|
CONF_SEND_SPLIT_DELAY,
|
|
default=current.get(CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY),
|
|
)
|
|
] = _duration_milliseconds_selector()
|
|
|
|
advanced_section = section(
|
|
vol.Schema(advanced_schema),
|
|
{"collapsed": True},
|
|
)
|
|
|
|
diagnostics_section = section(
|
|
vol.Schema(
|
|
{
|
|
vol.Required(
|
|
CONF_INCLUDE_CONFIG_IN_ATTRIBUTES,
|
|
default=current.get(CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, False),
|
|
): BooleanSelector(),
|
|
},
|
|
),
|
|
{"collapsed": True},
|
|
)
|
|
|
|
return vol.Schema(
|
|
{
|
|
vol.Required(SECTION_TARGETS): targets_section,
|
|
vol.Required(SECTION_DAYTIME): daytime_section,
|
|
vol.Required(SECTION_SUN): sun_section,
|
|
vol.Required(SECTION_LIGHT_CONTROL): light_control_section,
|
|
vol.Required(SECTION_ADVANCED): advanced_section,
|
|
vol.Required(SECTION_DIAGNOSTICS): diagnostics_section,
|
|
},
|
|
)
|
|
|
|
|
|
def _flatten_sections(sectioned: dict[str, Any]) -> dict[str, Any]:
|
|
"""Flatten a sectioned form result back into a flat options dict.
|
|
|
|
HA's section() wraps each section's values in a nested dict; we store
|
|
options flat (matching VALIDATION_TUPLES), so unwrap here.
|
|
"""
|
|
flat: dict[str, Any] = {}
|
|
for value in sectioned.values():
|
|
if isinstance(value, dict):
|
|
flat.update(value)
|
|
else:
|
|
flat[CONF_NAME] = value # unlikely fallback
|
|
return flat
|
|
|
|
|
|
class AdaptiveLightingConfigFlow(HAConfigFlow, domain=DOMAIN):
|
|
"""Handle a config flow for the CDiT Adaptive Lighting fork."""
|
|
|
|
VERSION = CONFIG_ENTRY_VERSION
|
|
|
|
async def async_step_user(self, user_input: dict[str, Any] | None = None):
|
|
"""Initial step: ask for a profile name."""
|
|
errors: dict[str, str] = {}
|
|
if user_input is not None:
|
|
await self.async_set_unique_id(user_input[CONF_NAME])
|
|
self._abort_if_unique_id_configured()
|
|
return self.async_create_entry(
|
|
title=user_input[CONF_NAME],
|
|
data=user_input,
|
|
)
|
|
return self.async_show_form(
|
|
step_id="user",
|
|
data_schema=vol.Schema({vol.Required(CONF_NAME): str}),
|
|
errors=errors,
|
|
)
|
|
|
|
async def async_step_import(self, user_input: dict[str, Any] | None = None):
|
|
"""Handle a YAML import.
|
|
|
|
YAML-configured entries can be loaded into HA but cannot be edited
|
|
via the options flow — see `OptionsFlowHandler.async_step_init`.
|
|
"""
|
|
if user_input is None:
|
|
return self.async_abort(reason="no_data")
|
|
await self.async_set_unique_id(user_input[CONF_NAME])
|
|
data = self.hass.data.setdefault(DOMAIN, {})
|
|
data.setdefault("__yaml__", set()).add(self.unique_id)
|
|
for entry in self._async_current_entries():
|
|
if entry.unique_id == self.unique_id:
|
|
self.hass.config_entries.async_update_entry(entry, data=user_input)
|
|
self._abort_if_unique_id_configured()
|
|
return self.async_create_entry(title=user_input[CONF_NAME], data=user_input)
|
|
|
|
@staticmethod
|
|
@callback
|
|
def async_get_options_flow(
|
|
config_entry: ConfigEntry, # noqa: ARG004
|
|
) -> "OptionsFlowHandler":
|
|
"""Get the options flow for this handler."""
|
|
return OptionsFlowHandler()
|
|
|
|
|
|
# Legacy alias so external code that does `from .config_flow import ConfigFlow`
|
|
# (such as upstream's docs/tests harness) still resolves.
|
|
ConfigFlow = AdaptiveLightingConfigFlow
|
|
|
|
|
|
class OptionsFlowHandler(OptionsFlowWithReload):
|
|
"""Sectioned options flow with reload-on-save.
|
|
|
|
Spec R6: extends OptionsFlowWithReload so saving triggers a clean
|
|
reload of the entry. Spec R7: YAML-managed entries abort with a
|
|
translation-keyed message instead of presenting an editable form.
|
|
"""
|
|
|
|
async def async_step_init(self, user_input: dict[str, Any] | None = None):
|
|
conf = self.config_entry
|
|
if conf.source == SOURCE_IMPORT:
|
|
return self.async_abort(reason="yaml_managed")
|
|
|
|
# Merge data and options to compute the effective "current" view.
|
|
current = dict(conf.data)
|
|
current.update(conf.options)
|
|
|
|
errors: dict[str, str] = {}
|
|
if user_input is not None:
|
|
flat = _flatten_sections(user_input)
|
|
# Validate at least one light still exists.
|
|
all_lights = set(self.hass.states.async_entity_ids("light"))
|
|
for configured_light in flat.get(CONF_LIGHTS, []):
|
|
if configured_light not in all_lights:
|
|
errors[CONF_LIGHTS] = "entity_missing"
|
|
_LOGGER.error(
|
|
"Adaptive Lighting: light entity %s is configured but not "
|
|
"currently registered. Aborting save.",
|
|
configured_light,
|
|
)
|
|
break
|
|
if not errors:
|
|
return self.async_create_entry(title="", data=flat)
|
|
|
|
return self.async_show_form(
|
|
step_id="init",
|
|
data_schema=_build_options_schema(
|
|
current,
|
|
show_send_split_delay=bool(
|
|
current.get(
|
|
CONF_SEPARATE_TURN_ON_COMMANDS,
|
|
DEFAULT_SEPARATE_TURN_ON_COMMANDS,
|
|
),
|
|
),
|
|
),
|
|
errors=errors,
|
|
)
|