mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-17 01:04:05 +02:00
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:
parent
c3b948874e
commit
7c565a5f5f
2 changed files with 163 additions and 15 deletions
|
|
@ -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 = "—"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue