adaptive-lighting/custom_components/adaptive_lighting/config_flow.py
Casey 501e3d639e Implement cdit-config-redesign groups 1-6 (30/53 tasks)
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).
2026-05-16 14:27:39 +02:00

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,
)