From 7c565a5f5f52d512a31dbad90268693560dad9dc Mon Sep 17 00:00:00 2001 From: Casey Date: Sat, 30 May 2026 22:06:59 +0200 Subject: [PATCH] Fix options-flow conditional reveal + empty lux-sensor save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two config-flow bugs in the Ambient lux / Advanced sections: - Conditional fields (target_lux, send_split_delay) only appeared on a fresh re-open of the dialog, never in the same session — violating the options-flow spec ('SHALL re-render ... in the same session'). Now, when a driver is enabled but its dependent field was hidden from the rendered schema, the form re-renders (carrying the user's edits) instead of saving. - The options form could not be saved at all without selecting a lux sensor: vol.Optional(CONF_LUX_SENSOR, default='') fed '' into the illuminance EntitySelector, which rejects empty strings. Omit the default (vol.UNDEFINED) when no sensor is configured. Extracted _has_pending_reveal() and _overlay_range_values() helpers to keep async_step_init under the branch limit. Added 3 flow-level tests exercising the real re-render path (prior tests only hit the schema builder, which is how these slipped through). 141 tests pass. --- .../adaptive_lighting/config_flow.py | 67 ++++++++--- tests/test_config_flow.py | 111 ++++++++++++++++++ 2 files changed, 163 insertions(+), 15 deletions(-) diff --git a/custom_components/adaptive_lighting/config_flow.py b/custom_components/adaptive_lighting/config_flow.py index f8282fb1..3f3ff68d 100644 --- a/custom_components/adaptive_lighting/config_flow.py +++ b/custom_components/adaptive_lighting/config_flow.py @@ -237,10 +237,16 @@ def _build_options_schema( {"collapsed": False}, ) + # An illuminance EntitySelector rejects an empty string, so we must NOT + # hand it `default=""`. When no sensor is configured, omit the default + # entirely (vol.UNDEFINED) so the field is simply absent on submit and + # treated as "unconfigured" — otherwise the form cannot be saved without + # selecting a lux sensor. + lux_default = current.get(CONF_LUX_SENSOR) or vol.UNDEFINED ambient_lux_schema: dict[Any, Any] = { vol.Optional( CONF_LUX_SENSOR, - default=current.get(CONF_LUX_SENSOR, DEFAULT_LUX_SENSOR), + default=lux_default, ): _lux_sensor_selector(), } if show_target_lux: @@ -362,6 +368,21 @@ def _flatten_sections(sectioned: dict[str, Any]) -> dict[str, Any]: return flat +def _has_pending_reveal(flat: dict[str, Any]) -> bool: + """Return True when a just-toggled driver hides a dependent field. + + HA's section() forms are not reactive, so when a user enables a driver + in this submission its dependent field was absent from the rendered + schema (and therefore from ``flat``). The caller re-shows the rebuilt + form so the dependent field appears in the same session, per the + options-flow conditional-visibility spec. + """ + return (bool(flat.get(CONF_LUX_SENSOR)) and CONF_TARGET_LUX not in flat) or ( + bool(flat.get(CONF_SEPARATE_TURN_ON_COMMANDS)) + and CONF_SEND_SPLIT_DELAY not in flat + ) + + class AdaptiveLightingConfigFlow(HAConfigFlow, domain=DOMAIN): """Handle a config flow for the CDiT Adaptive Lighting fork.""" @@ -422,6 +443,26 @@ class OptionsFlowHandler(OptionsFlowWithReload): translation-keyed message instead of presenting an editable form. """ + def _overlay_range_values(self, current: dict[str, Any]) -> None: + """Overlay live runtime-range number entity values onto ``current``. + + Keeps the dialog's brightness/color-temp defaults in sync with what + the four ``number`` entities are actually running (spec R6, D4). + """ + registry = er.async_get(self.hass) + for row in RANGE_ENTITIES: + unique_id = f"{self.config_entry.entry_id}_{row['field_key']}" + entity_id = registry.async_get_entity_id("number", DOMAIN, unique_id) + if entity_id is None: + continue + state = self.hass.states.get(entity_id) + if state is None or state.state in (None, "unavailable", "unknown"): + continue + try: + current[row["conf_key"]] = int(float(state.state)) + except (TypeError, ValueError): + continue + async def async_step_init(self, user_input: dict[str, Any] | None = None): conf = self.config_entry if conf.source == SOURCE_IMPORT: @@ -435,19 +476,7 @@ class OptionsFlowHandler(OptionsFlowWithReload): # so the dialog matches what the user's lights are actually running # (spec R6, design D4). Other ~14 fields keep their `entry.options` # values from above. - registry = er.async_get(self.hass) - for row in RANGE_ENTITIES: - unique_id = f"{conf.entry_id}_{row['field_key']}" - entity_id = registry.async_get_entity_id("number", DOMAIN, unique_id) - if entity_id is None: - continue - state = self.hass.states.get(entity_id) - if state is None or state.state in (None, "unavailable", "unknown"): - continue - try: - current[row["conf_key"]] = int(float(state.state)) - except (TypeError, ValueError): - continue + self._overlay_range_values(current) errors: dict[str, str] = {} if user_input is not None: @@ -464,7 +493,15 @@ class OptionsFlowHandler(OptionsFlowWithReload): ) break if not errors: - return self.async_create_entry(title="", data=flat) + if _has_pending_reveal(flat): + # Overlay the just-submitted values so the re-rendered + # form keeps the user's edits and recomputes conditional + # visibility from the new driver values below, instead of + # saving — the dependent field then appears in the same + # session (no save-and-reopen round-trip). + current.update(flat) + else: + return self.async_create_entry(title="", data=flat) lux_sensor_id = current.get(CONF_LUX_SENSOR, DEFAULT_LUX_SENSOR) lux_reading = "—" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 486b32f7..8728b120 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -440,3 +440,114 @@ async def test_options_flow_shows_lux_reading_placeholder(hass) -> None: assert result["type"] is FlowResultType.FORM assert "description_placeholders" in result assert "current_lux" in result["description_placeholders"] + + +# --------------------------------------------------------------------------- +# Same-session re-render of conditional fields (options-flow conditionals) +# --------------------------------------------------------------------------- + + +def _full_section_payload() -> dict: + """A schema-valid sectioned options submission (conditional fields off).""" + return { + SECTION_TARGETS: {CONF_LIGHTS: []}, + SECTION_DAYTIME: { + CONF_MIN_BRIGHTNESS: 5, + CONF_MAX_BRIGHTNESS: 100, + CONF_MIN_COLOR_TEMP: 2000, + CONF_MAX_COLOR_TEMP: 5500, + CONF_PREFER_RGB_COLOR: False, + }, + SECTION_SUN: { + CONF_SUNRISE_ENTITY: DEFAULT_SUNRISE_ENTITY, + CONF_SUNSET_ENTITY: DEFAULT_SUNSET_ENTITY, + }, + # Omit lux_sensor entirely: an EntitySelector rejects an explicit "", + # so the empty case is expressed by absence (the vol.Optional default). + SECTION_AMBIENT_LUX: {}, + SECTION_LIGHT_CONTROL: {CONF_INTERCEPT: True, CONF_MULTI_LIGHT_INTERCEPT: False}, + SECTION_ADVANCED: { + CONF_INTERVAL: 90, + "transition": 45, + "initial_transition": 1, + "adapt_delay": 0, + CONF_SEPARATE_TURN_ON_COMMANDS: False, + CONF_SKIP_REDUNDANT_COMMANDS: True, + }, + SECTION_DIAGNOSTICS: {CONF_INCLUDE_CONFIG_IN_ATTRIBUTES: False}, + } + + +def _result_section_fields(result, section_name: str) -> set[str]: + """Extract a section's field names from a rendered flow result schema.""" + schema = result["data_schema"].schema + marker = next( + m for m in schema if (m.schema if hasattr(m, "schema") else m) == section_name + ) + return _section_inner_keys(schema[marker]) + + +async def _open_options(hass): + entry = MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={CONF_NAME: DEFAULT_NAME}, + options={}, + version=2, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + return await hass.config_entries.options.async_init(entry.entry_id) + + +async def test_options_flow_saves_when_no_conditional_pending(hass) -> None: + """Regression: a submission with no driver enabled still saves directly.""" + result = await _open_options(hass) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=_full_section_payload(), + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_target_lux_revealed_same_session_when_sensor_selected(hass) -> None: + """options-flow conditionals: picking a lux sensor re-renders the form with + target_lux present, in the SAME session (no save-and-reopen round-trip). + """ + result = await _open_options(hass) + assert CONF_TARGET_LUX not in _result_section_fields(result, SECTION_AMBIENT_LUX) + + payload = _full_section_payload() + payload[SECTION_AMBIENT_LUX] = {CONF_LUX_SENSOR: "sensor.office_lux"} + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=payload, + ) + # Must re-render rather than save, and now expose target_lux. + assert result["type"] is FlowResultType.FORM + assert CONF_TARGET_LUX in _result_section_fields(result, SECTION_AMBIENT_LUX) + + +async def test_target_lux_saved_on_second_submit_after_reveal(hass) -> None: + """After the reveal re-render, submitting with target_lux set saves.""" + result = await _open_options(hass) + payload = _full_section_payload() + payload[SECTION_AMBIENT_LUX] = {CONF_LUX_SENSOR: "sensor.office_lux"} + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=payload, + ) + assert result["type"] is FlowResultType.FORM # revealed + + payload[SECTION_AMBIENT_LUX] = { + CONF_LUX_SENSOR: "sensor.office_lux", + CONF_TARGET_LUX: 500, + } + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=payload, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_LUX_SENSOR] == "sensor.office_lux" + assert result["data"][CONF_TARGET_LUX] == 500