Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 34 additions & 12 deletions .github/update-strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import sys
from pathlib import Path

import homeassistant.helpers.config_validation as cv
import yaml

sys.path.append(str(Path(__file__).parent.parent))
Expand All @@ -17,18 +16,37 @@
with strings_fname.open() as f:
strings = json.load(f)

# Set "options"
data = {}
data_description = {}
for k, _, typ in const.VALIDATION_TUPLES:
desc = const.DOCS[k]
if len(desc) > 40 and typ not in (bool, cv.entity_ids):
# Step option groupings
STEP_OPTIONS = {
"init": const.STEP_INIT_OPTIONS,
"sleep": const.STEP_SLEEP_OPTIONS,
"sun_timing": const.STEP_SUN_TIMING_OPTIONS,
"manual_control": const.STEP_MANUAL_CONTROL_OPTIONS,
"workarounds": const.STEP_WORKAROUNDS_OPTIONS,
}

# Set "options" per step
for step_name, step_keys in STEP_OPTIONS.items():
data = {}
data_description = {}
for k in step_keys:
desc = const.DOCS[k]
data[k] = k
data_description[k] = desc
# Add room_preset to init step
if step_name == "init":
data[const.CONF_ROOM_PRESET] = const.CONF_ROOM_PRESET
data_description[const.CONF_ROOM_PRESET] = const.DOCS[const.CONF_ROOM_PRESET]
if step_name in strings["options"]["step"]:
strings["options"]["step"][step_name]["data"] = data
strings["options"]["step"][step_name]["data_description"] = data_description
else:
data[k] = f"{k}: {desc}"
strings["options"]["step"]["init"]["data"] = data
strings["options"]["step"]["init"]["data_description"] = data_description
strings["options"]["step"][step_name] = {
"title": step_name,
"description": "",
"data": data,
"data_description": data_description,
}

# Set "services"
services_filename = Path("custom_components") / "adaptive_lighting" / "services.yaml"
Expand Down Expand Up @@ -58,8 +76,12 @@
en = json.load(f)

en["config"]["step"]["user"] = strings["config"]["step"]["user"]
en["options"]["step"]["init"]["data"] = data
en["options"]["step"]["init"]["data_description"] = data_description
for step_name in STEP_OPTIONS:
en.setdefault("options", {}).setdefault("step", {}).setdefault(step_name, {})
src = strings["options"]["step"][step_name]
tgt = en["options"]["step"][step_name]
tgt["data"] = src["data"]
tgt["data_description"] = src["data_description"]
en["services"] = services_json

with en_fname.open("w") as f:
Expand Down
227 changes: 196 additions & 31 deletions custom_components/adaptive_lighting/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,38 @@
from homeassistant import config_entries
from homeassistant.const import CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
from homeassistant.helpers.selector import (
EntitySelector,
EntitySelectorConfig,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)

from .const import ( # pylint: disable=unused-import
CONF_ADAPT_ONLY_ON_BARE_TURN_ON,
CONF_DETECT_NON_HA_CHANGES,
CONF_INTERCEPT,
CONF_LIGHTS,
CONF_MAX_BRIGHTNESS,
CONF_MAX_COLOR_TEMP,
CONF_MIN_BRIGHTNESS,
CONF_MIN_COLOR_TEMP,
CONF_MULTI_LIGHT_INTERCEPT,
CONF_ROOM_PRESET,
CONF_SEND_SPLIT_DELAY,
CONF_SEPARATE_TURN_ON_COMMANDS,
CONF_TAKE_OVER_CONTROL,
DOMAIN,
EXTRA_VALIDATION,
NONE_STR,
VALIDATION_TUPLES,
ROOM_PRESETS,
STEP_INIT_OPTIONS,
STEP_MANUAL_CONTROL_OPTIONS,
STEP_SLEEP_OPTIONS,
STEP_SUN_TIMING_OPTIONS,
STEP_WORKAROUNDS_OPTIONS,
VALIDATION_TUPLES_BY_KEY,
)
from .switch import validate

Expand Down Expand Up @@ -102,13 +126,20 @@ def async_get_options_flow(
return OptionsFlowHandler()


def validate_options(user_input: dict[str, Any], errors: dict[str, str]) -> None:
def validate_options(
user_input: dict[str, Any],
errors: dict[str, str],
step_keys: list[str] | None = None,
) -> None:
"""Validate the options in the OptionsFlow.

This is an extra validation step because the validators
in `EXTRA_VALIDATION` cannot be serialized to json.
"""
step_key_set = set(step_keys) if step_keys is not None else None
for key, (_validate, _) in EXTRA_VALIDATION.items():
if step_key_set is not None and key not in step_key_set:
continue
# these are unserializable validators
value = user_input.get(key)
try:
Expand All @@ -122,46 +153,180 @@ def validate_options(user_input: dict[str, Any], errors: dict[str, str]) -> None
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a option flow for Adaptive Lighting."""

def __init__(self) -> None:
"""Initialize options flow."""
self._options: dict[str, Any] = {}

def _build_step_schema(
self,
step_options: list[str],
extra_fields: dict | None = None,
) -> vol.Schema:
"""Build a schema for a specific step's options."""
conf = self.config_entry
to_replace: dict[str, Any] = {}
if CONF_LIGHTS in step_options:
to_replace[CONF_LIGHTS] = EntitySelector(
EntitySelectorConfig(domain="light", multiple=True),
)
options_schema = {}
for name in step_options:
_name, default, validation = VALIDATION_TUPLES_BY_KEY[name]
key = vol.Optional(
name,
default=self._options.get(name, conf.options.get(name, default)),
)
value = to_replace.get(name, validation)
options_schema[key] = value
if extra_fields:
options_schema.update(extra_fields)
return vol.Schema(options_schema)

async def async_step_init(self, user_input: dict[str, Any] | None = None):
"""Handle options flow."""
"""Step 1: Essentials — lights, brightness, color temp, and room preset."""
conf = self.config_entry
data = validate(conf)
validate(conf)
if conf.source == config_entries.SOURCE_IMPORT:
return self.async_show_form(step_id="init", data_schema=None)

errors: dict[str, str] = {}
if user_input is not None:
validate_options(user_input, errors)
# Apply room preset overrides
preset = user_input.pop(CONF_ROOM_PRESET, "custom")
if preset != "custom":
preset_values = ROOM_PRESETS.get(preset, {})
for key, value in preset_values.items():
if key in user_input:
user_input[key] = value
else:
# Store cross-step values for later steps
self._options[key] = value

# Validate options
validate_options(user_input, errors, STEP_INIT_OPTIONS)

# Range validation
if user_input.get(CONF_MIN_BRIGHTNESS, 1) > user_input.get(
CONF_MAX_BRIGHTNESS,
100,
):
errors[CONF_MIN_BRIGHTNESS] = "brightness_range_invalid"
if user_input.get(CONF_MIN_COLOR_TEMP, 2000) > user_input.get(
CONF_MAX_COLOR_TEMP,
5500,
):
errors[CONF_MIN_COLOR_TEMP] = "color_temp_range_invalid"

# Light entity existence check
all_lights = set(self.hass.states.async_entity_ids("light"))
for configured_light in user_input.get(CONF_LIGHTS, []):
if configured_light not in all_lights:
errors[CONF_LIGHTS] = "entity_missing"
break

if not errors:
return self.async_create_entry(title="", data=user_input)

# Validate that all configured lights still exist
all_lights = set(self.hass.states.async_entity_ids("light"))
for configured_light in data[CONF_LIGHTS]:
if configured_light not in all_lights:
errors = {CONF_LIGHTS: "entity_missing"}
_LOGGER.error(
"%s: light entity %s is configured, but was not found",
data[CONF_NAME],
configured_light,
)

to_replace: dict[str, Any] = {
CONF_LIGHTS: EntitySelector(
EntitySelectorConfig(
domain="light",
multiple=True,
self._options.update(user_input)
return await self.async_step_sleep()

# Build schema with room preset selector
preset_selector = {
vol.Optional(CONF_ROOM_PRESET, default="custom"): SelectSelector(
SelectSelectorConfig(
options=["custom", *ROOM_PRESETS],
multiple=False,
mode=SelectSelectorMode.DROPDOWN,
),
),
}

options_schema = {}
for name, default, validation in VALIDATION_TUPLES:
key = vol.Optional(name, default=conf.options.get(name, default))
value = to_replace.get(name, validation)
options_schema[key] = value
schema = self._build_step_schema(
STEP_INIT_OPTIONS,
extra_fields=preset_selector,
)

return self.async_show_form(
step_id="init",
data_schema=vol.Schema(options_schema),
data_schema=schema,
errors=errors,
)

async def async_step_sleep(self, user_input: dict[str, Any] | None = None):
"""Step 2: Sleep mode settings."""
errors: dict[str, str] = {}
if user_input is not None:
validate_options(user_input, errors, STEP_SLEEP_OPTIONS)
if not errors:
self._options.update(user_input)
return await self.async_step_sun_timing()

return self.async_show_form(
step_id="sleep",
data_schema=self._build_step_schema(STEP_SLEEP_OPTIONS),
errors=errors,
)

async def async_step_sun_timing(self, user_input: dict[str, Any] | None = None):
"""Step 3: Sun position and timing settings."""
errors: dict[str, str] = {}
if user_input is not None:
validate_options(user_input, errors, STEP_SUN_TIMING_OPTIONS)
if not errors:
self._options.update(user_input)
return await self.async_step_manual_control()

return self.async_show_form(
step_id="sun_timing",
data_schema=self._build_step_schema(STEP_SUN_TIMING_OPTIONS),
errors=errors,
)

async def async_step_manual_control(self, user_input: dict[str, Any] | None = None):
"""Step 4: Behavior — manual control and interception settings."""
errors: dict[str, str] = {}
if user_input is not None:
validate_options(user_input, errors, STEP_MANUAL_CONTROL_OPTIONS)

# Dependency validation
if user_input.get(CONF_DETECT_NON_HA_CHANGES) and not user_input.get(
CONF_TAKE_OVER_CONTROL,
):
errors[CONF_DETECT_NON_HA_CHANGES] = "requires_take_over_control"
if user_input.get(CONF_ADAPT_ONLY_ON_BARE_TURN_ON) and not user_input.get(
CONF_TAKE_OVER_CONTROL,
):
errors[CONF_ADAPT_ONLY_ON_BARE_TURN_ON] = "requires_take_over_control"
if user_input.get(CONF_MULTI_LIGHT_INTERCEPT) and not user_input.get(
CONF_INTERCEPT,
):
errors[CONF_MULTI_LIGHT_INTERCEPT] = "requires_intercept"

if not errors:
self._options.update(user_input)
return await self.async_step_workarounds()

return self.async_show_form(
step_id="manual_control",
data_schema=self._build_step_schema(STEP_MANUAL_CONTROL_OPTIONS),
errors=errors,
)

async def async_step_workarounds(self, user_input: dict[str, Any] | None = None):
"""Step 5: Device workarounds."""
errors: dict[str, str] = {}
if user_input is not None:
validate_options(user_input, errors, STEP_WORKAROUNDS_OPTIONS)

# Dependency validation
if user_input.get(CONF_SEND_SPLIT_DELAY, 0) > 0 and not user_input.get(
CONF_SEPARATE_TURN_ON_COMMANDS,
):
errors[CONF_SEND_SPLIT_DELAY] = "requires_separate_turn_on"

if not errors:
self._options.update(user_input)
return self.async_create_entry(title="", data=self._options)

return self.async_show_form(
step_id="workarounds",
data_schema=self._build_step_schema(STEP_WORKAROUNDS_OPTIONS),
errors=errors,
)
Loading