Fix options-flow conditional reveal + empty lux-sensor save

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.
This commit is contained in:
Casey 2026-05-30 22:06:59 +02:00
commit 7c565a5f5f
2 changed files with 163 additions and 15 deletions

View file

@ -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 = ""

View file

@ -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