Skip to content
Open
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,8 @@ dmypy.json
# Home Assistant configuration
config/*
!config/configuration.yaml

# Local directories
core/
oss-ai-manager/
.DS_Store
53 changes: 43 additions & 10 deletions custom_components/adaptive_lighting/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
from homeassistant import config_entries
from homeassistant.const import CONF_NAME, MAJOR_VERSION, MINOR_VERSION
from homeassistant.core import callback
from homeassistant.data_entry_flow import section

from .const import ( # pylint: disable=unused-import
BASIC_OPTIONS,
CONF_LIGHTS,
DOMAIN,
EXTRA_VALIDATION,
Expand Down Expand Up @@ -99,18 +101,32 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
else:
self.config_entry = args[0]

def _flatten_section_input(self, user_input: dict[str, Any]) -> dict[str, Any]:
"""Flatten section input by merging nested 'advanced' dict into top level."""
flat_input: dict[str, Any] = {}
for key, value in user_input.items():
if key == "advanced" and isinstance(value, dict):
flat_input.update(value)
else:
flat_input[key] = value
return flat_input

async def async_step_init(self, user_input: dict[str, Any] | None = None):
"""Handle options flow."""
"""Handle options flow with collapsible sections."""
conf = self.config_entry
data = validate(conf)
if conf.source == config_entries.SOURCE_IMPORT:
return self.async_show_form(step_id="init", data_schema=None)
errors = {}

errors: dict[str, str] = {}
if user_input is not None:
validate_options(user_input, errors)
# Flatten section data before validation
flat_input = self._flatten_section_input(user_input)
validate_options(flat_input, errors)
if not errors:
return self.async_create_entry(title="", data=user_input)
return self.async_create_entry(title="", data=flat_input)

# Build light selector
all_lights_with_names = {
light: get_friendly_name(self.hass, light)
for light in self.hass.states.async_entity_ids("light")
Expand All @@ -119,7 +135,7 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None):
all_lights = list(all_lights_with_names.keys())
for configured_light in data[CONF_LIGHTS]:
if configured_light not in all_lights:
errors = {CONF_LIGHTS: "entity_missing"}
errors[CONF_LIGHTS] = "entity_missing"
_LOGGER.error(
"%s: light entity %s is configured, but was not found",
data[CONF_NAME],
Expand All @@ -134,14 +150,31 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None):
}
to_replace = {CONF_LIGHTS: cv.multi_select(light_options)}

options_schema = {}
# Build basic options schema (always visible)
basic_schema: dict[vol.Optional, Any] = {}
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
if name in BASIC_OPTIONS:
key = vol.Optional(name, default=conf.options.get(name, default))
basic_schema[key] = to_replace.get(name, validation)

# Build advanced options schema (collapsed by default)
advanced_schema: dict[vol.Optional, Any] = {}
for name, default, validation in VALIDATION_TUPLES:
if name not in BASIC_OPTIONS:
key = vol.Optional(name, default=conf.options.get(name, default))
advanced_schema[key] = to_replace.get(name, validation)

# Combine: basic fields + collapsed advanced section
full_schema = {
**basic_schema,
vol.Required("advanced"): section(
vol.Schema(advanced_schema),
{"collapsed": True},
),
}

return self.async_show_form(
step_id="init",
data_schema=vol.Schema(options_schema),
data_schema=vol.Schema(full_schema),
errors=errors,
)
13 changes: 13 additions & 0 deletions custom_components/adaptive_lighting/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,19 @@
CONF_LIGHTS: "A light (or list of lights) to apply the settings to. 💡",
}

# Basic options shown at top level in options flow (not in collapsed section)
BASIC_OPTIONS: set[str] = {
CONF_LIGHTS,
CONF_MIN_BRIGHTNESS,
CONF_MAX_BRIGHTNESS,
CONF_MIN_COLOR_TEMP,
CONF_MAX_COLOR_TEMP,
CONF_SLEEP_BRIGHTNESS,
CONF_SLEEP_COLOR_TEMP,
CONF_TRANSITION,
CONF_INTERVAL,
}


def int_between(min_int: int, max_int: int) -> vol.All:
"""Return an integer between 'min_int' and 'max_int'."""
Expand Down
57 changes: 57 additions & 0 deletions custom_components/adaptive_lighting/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,63 @@
"autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
"send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
"adapt_delay": "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️"
},
"sections": {
"advanced": {
"name": "Advanced Settings",
"description": "Additional configuration options for fine-tuning behavior",
"data": {
"initial_transition": "initial_transition",
"prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈",
"sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp",
"sleep_rgb_color": "sleep_rgb_color",
"sleep_transition": "sleep_transition",
"transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙",
"sunrise_time": "sunrise_time",
"min_sunrise_time": "min_sunrise_time",
"max_sunrise_time": "max_sunrise_time",
"sunrise_offset": "sunrise_offset",
"sunset_time": "sunset_time",
"min_sunset_time": "min_sunset_time",
"max_sunset_time": "max_sunset_time",
"sunset_offset": "sunset_offset",
"brightness_mode": "brightness_mode",
"brightness_mode_time_dark": "brightness_mode_time_dark",
"brightness_mode_time_light": "brightness_mode_time_light",
"take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒",
"detect_non_ha_changes": "detect_non_ha_changes: Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Disable this feature if you encounter such issues.",
"autoreset_control_seconds": "autoreset_control_seconds",
"only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄",
"adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️",
"separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀",
"send_split_delay": "send_split_delay",
"adapt_delay": "adapt_delay",
"skip_redundant_commands": "skip_redundant_commands: Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. 📉Disable if physical light states get out of sync with HA's recorded state.",
"intercept": "intercept: Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. 🏎️ Disable for lights that do not support `light.turn_on` with color and brightness.",
"multi_light_intercept": "multi_light_intercept: Intercept and adapt `light.turn_on` calls that target multiple lights. ➗⚠️ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. Requires `intercept` to be enabled.",
"include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝"
},
"data_description": {
"initial_transition": "Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️",
"sleep_rgb_or_color_temp": "Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙",
"sleep_rgb_color": "RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈",
"sleep_transition": "Duration of transition when \"sleep mode\" is toggled in seconds. 😴",
"sunrise_time": "Set a fixed time (HH:MM:SS) for sunrise. 🌅",
"min_sunrise_time": "Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅",
"max_sunrise_time": "Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅",
"sunrise_offset": "Adjust sunrise time with a positive or negative offset in seconds. ⏰",
"sunset_time": "Set a fixed time (HH:MM:SS) for sunset. 🌇",
"min_sunset_time": "Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. 🌇",
"max_sunset_time": "Set the latest virtual sunset time (HH:MM:SS), allowing for earlier sunsets. 🌇",
"sunset_offset": "Adjust sunset time with a positive or negative offset in seconds. ⏰",
"brightness_mode": "Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈",
"brightness_mode_time_dark": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉",
"brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.",
"autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
"send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
"adapt_delay": "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️"
}
}
}
}
},
Expand Down
46 changes: 35 additions & 11 deletions tests/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Test Adaptive Lighting config flow."""

from homeassistant.components.adaptive_lighting.const import (
BASIC_OPTIONS,
CONF_SUNRISE_TIME,
CONF_SUNSET_TIME,
DEFAULT_NAME,
Expand All @@ -16,6 +17,12 @@

DEFAULT_DATA = {key: default for key, default, _ in VALIDATION_TUPLES}

# Split DEFAULT_DATA into basic and advanced for section-based input
BASIC_DATA = {key: value for key, value in DEFAULT_DATA.items() if key in BASIC_OPTIONS}
ADVANCED_DATA = {
key: value for key, value in DEFAULT_DATA.items() if key not in BASIC_OPTIONS
}


async def test_flow_manual_configuration(hass):
"""Test that config flow works."""
Expand Down Expand Up @@ -53,7 +60,7 @@ async def test_import_success(hass):


async def test_options(hass):
"""Test updating options."""
"""Test updating options with collapsible sections."""
entry = MockConfigEntry(
domain=DOMAIN,
title=DEFAULT_NAME,
Expand All @@ -68,20 +75,28 @@ async def test_options(hass):
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "init"

data = DEFAULT_DATA.copy()
data[CONF_SUNRISE_TIME] = NONE_STR
data[CONF_SUNSET_TIME] = NONE_STR
# Build input with advanced options nested in "advanced" section
advanced_data = ADVANCED_DATA.copy()
advanced_data[CONF_SUNRISE_TIME] = NONE_STR
advanced_data[CONF_SUNSET_TIME] = NONE_STR
user_input = {
**BASIC_DATA,
"advanced": advanced_data,
}
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input=data,
user_input=user_input,
)
assert result["type"] == FlowResultType.CREATE_ENTRY
for key, value in data.items():

# Verify flattened data is saved correctly
expected_data = {**BASIC_DATA, **advanced_data}
for key, value in expected_data.items():
assert result["data"][key] == value


async def test_incorrect_options(hass):
"""Test updating incorrect options."""
"""Test updating incorrect options in advanced section."""
entry = MockConfigEntry(
domain=DOMAIN,
title=DEFAULT_NAME,
Expand All @@ -93,13 +108,22 @@ async def test_incorrect_options(hass):
await hass.config_entries.async_setup(entry.entry_id)

result = await hass.config_entries.options.async_init(entry.entry_id)
data = DEFAULT_DATA.copy()
data[CONF_SUNRISE_TIME] = "yolo"
data[CONF_SUNSET_TIME] = "yolo"

# Build input with invalid advanced options nested in section
advanced_data = ADVANCED_DATA.copy()
advanced_data[CONF_SUNRISE_TIME] = "yolo"
advanced_data[CONF_SUNSET_TIME] = "yolo"
user_input = {
**BASIC_DATA,
"advanced": advanced_data,
}
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input=data,
user_input=user_input,
)
# Should show form with errors
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "option_error"}


async def test_import_twice(hass):
Expand Down