Fix OptionsFlowWithReload incompatibility with add_update_listener

Production HA caught this on the first save attempt: HA rejects
`config_entry.add_update_listener()` when the integration also uses
`OptionsFlowWithReload`, raising:

    ValueError: Config entry update listeners should not be used with
    OptionsFlowWithReload

The listener was a vestigial bit of the pre-redesign reload plumbing
that should have been deleted alongside the manual reload path (design
decision D6). Removed:

- `config_entry.add_update_listener(async_update_options)` registration
  in `async_setup_entry`
- `async_update_options` helper (only existed to call `async_reload`)
- `UNDO_UPDATE_LISTENER` tracking in `async_unload_entry`
- The `UNDO_UPDATE_LISTENER` import (still defined in const.py for any
  external code that imports it; pruning that is a separate cleanup)

Updated `tests/test_init.py::test_successful_setup_on_current_version`
to assert `entry.entry_id in hass.data[DOMAIN]` instead of the listener
key. All 94 tests still pass locally.

This is the first real-HA-only bug we've shipped — props to the
production deploy for catching it before merge.
This commit is contained in:
Casey 2026-05-16 16:23:23 +02:00
commit 249d18689e
3 changed files with 326 additions and 13 deletions

315
.ha_snapshot_pre_cdit.json Normal file
View file

@ -0,0 +1,315 @@
{
"master_switches": [
"switch.adaptive_lighting_brightness_ent",
"switch.adaptive_lighting_color_ent",
"switch.adaptive_lighting_dining_mvp",
"switch.adaptive_lighting_ent",
"switch.adaptive_lighting_ko_circadian_lights",
"switch.adaptive_lighting_lr_dark_lights",
"switch.adaptive_lighting_no_brightness_control",
"switch.adaptive_lighting_other_circadian_lights",
"switch.adaptive_lighting_pre_release",
"switch.adaptive_lighting_sleep_ent"
],
"configurations": {
"switch.adaptive_lighting_brightness_ent": {},
"switch.adaptive_lighting_color_ent": {},
"switch.adaptive_lighting_dining_mvp": {
"lights": [
"light.liv_lt_lights",
"light.kit_lt_tablelamp"
],
"interval": 90.0,
"transition": 45.0,
"initial_transition": 1.0,
"min_brightness": 49,
"max_brightness": 82,
"min_color_temp": 2000,
"max_color_temp": 4000,
"prefer_rgb_color": false,
"sleep_brightness": 1,
"sleep_rgb_or_color_temp": "color_temp",
"sleep_color_temp": 1000,
"sleep_rgb_color": [
255,
56,
0
],
"sleep_transition": 1.0,
"transition_until_sleep": false,
"sunrise_time": null,
"min_sunrise_time": null,
"max_sunrise_time": null,
"sunrise_offset": 0.0,
"sunset_time": null,
"min_sunset_time": null,
"max_sunset_time": null,
"sunset_offset": 0.0,
"brightness_mode": "tanh",
"brightness_mode_time_dark": 900.0,
"brightness_mode_time_light": 3600.0,
"take_over_control": false,
"take_over_control_mode": "pause_all",
"detect_non_ha_changes": false,
"autoreset_control_seconds": 0,
"only_once": false,
"adapt_only_on_bare_turn_on": false,
"separate_turn_on_commands": false,
"send_split_delay": 0,
"adapt_delay": 0.0,
"skip_redundant_commands": true,
"intercept": true,
"multi_light_intercept": true,
"include_config_in_attributes": true,
"name": "dining_mvp"
},
"switch.adaptive_lighting_ent": {
"lights": [
"light.hlu_lt_1"
],
"interval": 90.0,
"transition": 45.0,
"initial_transition": 1.0,
"min_brightness": 10,
"max_brightness": 100,
"min_color_temp": 2000,
"max_color_temp": 5500,
"prefer_rgb_color": false,
"sleep_brightness": 1,
"sleep_rgb_or_color_temp": "color_temp",
"sleep_color_temp": 1000,
"sleep_rgb_color": [
255,
56,
0
],
"sleep_transition": 1.0,
"transition_until_sleep": false,
"sunrise_time": null,
"min_sunrise_time": null,
"max_sunrise_time": null,
"sunrise_offset": 0.0,
"sunset_time": null,
"min_sunset_time": null,
"max_sunset_time": null,
"sunset_offset": 0.0,
"brightness_mode": "tanh",
"brightness_mode_time_dark": 900.0,
"brightness_mode_time_light": 3600.0,
"take_over_control": false,
"take_over_control_mode": "pause_all",
"detect_non_ha_changes": false,
"autoreset_control_seconds": 0,
"only_once": false,
"adapt_only_on_bare_turn_on": false,
"separate_turn_on_commands": false,
"send_split_delay": 0,
"adapt_delay": 0.0,
"skip_redundant_commands": true,
"intercept": true,
"multi_light_intercept": true,
"include_config_in_attributes": true,
"name": "entrance_lights"
},
"switch.adaptive_lighting_ko_circadian_lights": {
"lights": [
"light.hll_lt_main",
"light.oke_lt_terrarium",
"light.oke_lt_strip",
"light.oke_lt_ceil",
"light.oke_lt_bs"
],
"interval": 90.0,
"transition": 15.0,
"initial_transition": 1.0,
"min_brightness": 40,
"max_brightness": 70,
"min_color_temp": 2000,
"max_color_temp": 5996,
"prefer_rgb_color": false,
"sleep_brightness": 5,
"sleep_rgb_or_color_temp": "color_temp",
"sleep_color_temp": 1000,
"sleep_rgb_color": [
255,
56,
0
],
"sleep_transition": 1.0,
"transition_until_sleep": false,
"sunrise_time": null,
"min_sunrise_time": null,
"max_sunrise_time": null,
"sunrise_offset": 0.0,
"sunset_time": null,
"min_sunset_time": null,
"max_sunset_time": null,
"sunset_offset": 0.0,
"brightness_mode": "tanh",
"brightness_mode_time_dark": 900.0,
"brightness_mode_time_light": 3600.0,
"take_over_control": false,
"take_over_control_mode": "pause_all",
"detect_non_ha_changes": false,
"autoreset_control_seconds": 0,
"only_once": false,
"adapt_only_on_bare_turn_on": false,
"separate_turn_on_commands": false,
"send_split_delay": 0,
"adapt_delay": 0.0,
"skip_redundant_commands": true,
"intercept": true,
"multi_light_intercept": true,
"include_config_in_attributes": true,
"name": "ko_circadian_lights"
},
"switch.adaptive_lighting_lr_dark_lights": {
"lights": [
"light.liv_massive_standing_li",
"light.liv_lt_ceiling"
],
"interval": 90.0,
"transition": 45.0,
"initial_transition": 1.0,
"min_brightness": 18,
"max_brightness": 34,
"min_color_temp": 2000,
"max_color_temp": 5500,
"prefer_rgb_color": false,
"sleep_brightness": 1,
"sleep_rgb_or_color_temp": "color_temp",
"sleep_color_temp": 1000,
"sleep_rgb_color": [
255,
56,
0
],
"sleep_transition": 1.0,
"transition_until_sleep": false,
"sunrise_time": null,
"min_sunrise_time": null,
"max_sunrise_time": null,
"sunrise_offset": 0.0,
"sunset_time": null,
"min_sunset_time": null,
"max_sunset_time": null,
"sunset_offset": 0.0,
"brightness_mode": "default",
"brightness_mode_time_dark": 900.0,
"brightness_mode_time_light": 3600.0,
"take_over_control": false,
"take_over_control_mode": "pause_all",
"detect_non_ha_changes": false,
"autoreset_control_seconds": 0,
"only_once": false,
"adapt_only_on_bare_turn_on": false,
"separate_turn_on_commands": false,
"send_split_delay": 0,
"adapt_delay": 0.0,
"skip_redundant_commands": false,
"intercept": true,
"multi_light_intercept": true,
"include_config_in_attributes": true,
"name": "lr dark lights"
},
"switch.adaptive_lighting_no_brightness_control": {
"lights": [
"light.hwr_ceiling",
"light.wc_lt_1"
],
"interval": 60.0,
"transition": 5.0,
"initial_transition": 1.0,
"min_brightness": 100,
"max_brightness": 100,
"min_color_temp": 2000,
"max_color_temp": 5500,
"prefer_rgb_color": false,
"sleep_brightness": 100,
"sleep_rgb_or_color_temp": "color_temp",
"sleep_color_temp": 1000,
"sleep_rgb_color": [
255,
56,
0
],
"sleep_transition": 1.0,
"transition_until_sleep": false,
"sunrise_time": null,
"min_sunrise_time": null,
"max_sunrise_time": null,
"sunrise_offset": 0.0,
"sunset_time": null,
"min_sunset_time": null,
"max_sunset_time": null,
"sunset_offset": 0.0,
"brightness_mode": "tanh",
"brightness_mode_time_dark": 900.0,
"brightness_mode_time_light": 3600.0,
"take_over_control": false,
"take_over_control_mode": "pause_all",
"detect_non_ha_changes": false,
"autoreset_control_seconds": 0,
"only_once": false,
"adapt_only_on_bare_turn_on": false,
"separate_turn_on_commands": false,
"send_split_delay": 0,
"adapt_delay": 0.0,
"skip_redundant_commands": true,
"intercept": true,
"multi_light_intercept": true,
"include_config_in_attributes": true,
"name": "no_brightness_control"
},
"switch.adaptive_lighting_other_circadian_lights": {
"lights": [
"light.oem_lt_closet"
],
"interval": 90.0,
"transition": 15.0,
"initial_transition": 1.0,
"min_brightness": 10,
"max_brightness": 100,
"min_color_temp": 2200,
"max_color_temp": 4000,
"prefer_rgb_color": false,
"sleep_brightness": 1,
"sleep_rgb_or_color_temp": "color_temp",
"sleep_color_temp": 1000,
"sleep_rgb_color": [
255,
56,
0
],
"sleep_transition": 1.0,
"transition_until_sleep": false,
"sunrise_time": null,
"min_sunrise_time": null,
"max_sunrise_time": null,
"sunrise_offset": 0.0,
"sunset_time": null,
"min_sunset_time": null,
"max_sunset_time": null,
"sunset_offset": 0.0,
"brightness_mode": "tanh",
"brightness_mode_time_dark": 900.0,
"brightness_mode_time_light": 3600.0,
"take_over_control": false,
"take_over_control_mode": "pause_all",
"detect_non_ha_changes": false,
"autoreset_control_seconds": 0,
"only_once": false,
"adapt_only_on_bare_turn_on": false,
"separate_turn_on_commands": false,
"send_split_delay": 0,
"adapt_delay": 0.0,
"skip_redundant_commands": true,
"intercept": true,
"multi_light_intercept": true,
"include_config_in_attributes": true,
"name": "other_circadian_lights"
},
"switch.adaptive_lighting_pre_release": {},
"switch.adaptive_lighting_sleep_ent": {}
}
}

View file

@ -17,7 +17,6 @@ from .const import (
CONF_NAME,
CONFIG_ENTRY_VERSION,
DOMAIN,
UNDO_UPDATE_LISTENER,
)
_LOGGER = logging.getLogger(__name__)
@ -116,7 +115,12 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up the component."""
"""Set up the component.
Note: `OptionsFlowWithReload` (in config_flow.py) handles reload-on-save
automatically HA rejects `config_entry.add_update_listener` when used
with `OptionsFlowWithReload`, so we deliberately do NOT register one.
"""
_remove_orphan_sleep_entities(hass, config_entry)
data = hass.data.setdefault(DOMAIN, {})
@ -125,18 +129,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
# and explicit `hass.reload_config_entry` calls).
hass.bus.async_listen("hass.config.entry_updated", reload_configuration_yaml)
undo_listener = config_entry.add_update_listener(async_update_options)
data[config_entry.entry_id] = {UNDO_UPDATE_LISTENER: undo_listener}
data[config_entry.entry_id] = {}
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Update options."""
await hass.config_entries.async_reload(config_entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_forward_entry_unload(
@ -144,9 +142,8 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
"switch",
)
data = hass.data[DOMAIN]
data[config_entry.entry_id][UNDO_UPDATE_LISTENER]()
if unload_ok:
data.pop(config_entry.entry_id)
data.pop(config_entry.entry_id, None)
if len(data) == 1 and ATTR_ADAPTIVE_LIGHTING_MANAGER in data:
# no more config_entries

View file

@ -16,7 +16,6 @@ from custom_components.adaptive_lighting.const import (
CONFIG_ENTRY_VERSION,
DEFAULT_NAME,
DOMAIN,
UNDO_UPDATE_LISTENER,
)
# ---------------------------------------------------------------------------
@ -35,7 +34,9 @@ async def test_successful_setup_on_current_version(hass) -> None:
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
assert entry.state == ConfigEntryState.LOADED
assert UNDO_UPDATE_LISTENER in hass.data[DOMAIN][entry.entry_id]
# OptionsFlowWithReload handles reload internally — we don't register
# an update listener (HA rejects the combination).
assert entry.entry_id in hass.data[DOMAIN]
async def test_stale_version_raises_config_entry_error(hass, caplog) -> None: