From 61c2b3068c30a1e38dfb6f974c56b860fba204c4 Mon Sep 17 00:00:00 2001 From: rhammen <75572839+rhammen@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:22:05 +0200 Subject: [PATCH] =?UTF-8?q?fix(config=5Fflow):=20=F0=9F=90=9B=20stop=20opt?= =?UTF-8?q?ions=20flow=20crashing=20with=20a=20500=20on=20default=20update?= =?UTF-8?q?=20interval?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add DEFAULT_UPDATE_INTERVAL_OPTION, the UPDATE_INTERVAL_OPTIONS string key matching DEFAULT_UPDATE_INTERVAL, derived from the dict so the two can't drift apart - use it instead of DEFAULT_UPDATE_INTERVAL as the options flow's CONF_UPDATE_INTERVAL fallback in config_flow.py and schema_helper.py - DEFAULT_UPDATE_INTERVAL is a timedelta, correct for the coordinator's own polling fallback, but wrong for the options form: its SelectSelector default/suggested_value must be one of UPDATE_INTERVAL_OPTIONS' string keys. Any entry that had never saved options before (the common case) hit this fallback and got a timedelta in the schema, which the frontend can't serialize to JSON - exactly the 500 reported in #656, confirmed against a user's log: "Bad data found at $.data_schema[1].default=0:01:00(" - add regression tests: build_options_schema() with no stored interval now round-trips through voluptuous_serialize + json.dumps exactly as the frontend does, and a const-level test pins DEFAULT_UPDATE_INTERVAL_OPTION to a valid UPDATE_INTERVAL_OPTIONS key Resolves #656 Co-Authored-By: Claude Sonnet 5 --- custom_components/luxtronik2/config_flow.py | 6 +++--- custom_components/luxtronik2/const.py | 9 +++++++++ custom_components/luxtronik2/schema_helper.py | 7 ++++--- tests/test_const.py | 14 ++++++++++++++ tests/test_schema_helper.py | 18 ++++++++++++++++++ 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/custom_components/luxtronik2/config_flow.py b/custom_components/luxtronik2/config_flow.py index 8e8a2382..5813f29a 100644 --- a/custom_components/luxtronik2/config_flow.py +++ b/custom_components/luxtronik2/config_flow.py @@ -25,7 +25,7 @@ DEFAULT_MAX_DATA_LENGTH, DEFAULT_PORT, DEFAULT_TIMEOUT, - DEFAULT_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL_OPTION, DOMAIN, LOGGER, ) @@ -512,7 +512,7 @@ async def async_step_user( new_options[CONF_HA_SENSOR_INDOOR_TEMPERATURE] = None update_interval = user_input.get( - CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL + CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL_OPTION ) new_options[CONF_UPDATE_INTERVAL] = update_interval @@ -520,7 +520,7 @@ async def async_step_user( current_indoor_temp = self._get_value(CONF_HA_SENSOR_INDOOR_TEMPERATURE) current_interval = self._get_value( - CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL + CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL_OPTION ) return self.async_show_form( diff --git a/custom_components/luxtronik2/const.py b/custom_components/luxtronik2/const.py index 8167037c..d443c45a 100644 --- a/custom_components/luxtronik2/const.py +++ b/custom_components/luxtronik2/const.py @@ -42,6 +42,15 @@ "1 minute (default)": timedelta(seconds=60), "5 minutes": timedelta(minutes=5), } +# The UPDATE_INTERVAL_OPTIONS key matching DEFAULT_UPDATE_INTERVAL. The options +# flow's CONF_UPDATE_INTERVAL field is a SelectSelector whose default/suggested +# value must be one of these string keys - DEFAULT_UPDATE_INTERVAL itself is a +# timedelta and isn't JSON-serializable when used there. +DEFAULT_UPDATE_INTERVAL_OPTION: Final = next( + key + for key, value in UPDATE_INTERVAL_OPTIONS.items() + if value == DEFAULT_UPDATE_INTERVAL +) UPDATE_INTERVAL_FAST: Final = timedelta(seconds=10) UPDATE_INTERVAL_NORMAL: Final = timedelta(minutes=1) diff --git a/custom_components/luxtronik2/schema_helper.py b/custom_components/luxtronik2/schema_helper.py index e5409701..6d1be5c8 100644 --- a/custom_components/luxtronik2/schema_helper.py +++ b/custom_components/luxtronik2/schema_helper.py @@ -10,7 +10,7 @@ DEFAULT_MAX_DATA_LENGTH, DEFAULT_PORT, DEFAULT_TIMEOUT, - DEFAULT_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL_OPTION, UPDATE_INTERVAL_OPTIONS, ) @@ -53,9 +53,10 @@ def build_options_schema( ), vol.Optional( CONF_UPDATE_INTERVAL, - default=current_interval or DEFAULT_UPDATE_INTERVAL, + default=current_interval or DEFAULT_UPDATE_INTERVAL_OPTION, description={ - "suggested_value": current_interval or DEFAULT_UPDATE_INTERVAL + "suggested_value": current_interval + or DEFAULT_UPDATE_INTERVAL_OPTION }, ): selector.SelectSelector( selector.SelectSelectorConfig( diff --git a/tests/test_const.py b/tests/test_const.py index 93887386..17216e8d 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -14,6 +14,7 @@ DEFAULT_PORT, DEFAULT_TIMEOUT, DEFAULT_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL_OPTION, DOMAIN, PLATFORMS, UPDATE_INTERVAL_OPTIONS, @@ -198,6 +199,19 @@ def test_update_interval_options_keys_and_timedeltas(self): assert UPDATE_INTERVAL_OPTIONS["1 minute (default)"].total_seconds() == 60 assert UPDATE_INTERVAL_OPTIONS["5 minutes"].total_seconds() == 300 + def test_default_update_interval_option_is_a_valid_key(self): + """DEFAULT_UPDATE_INTERVAL_OPTION must be a string key of + UPDATE_INTERVAL_OPTIONS, not the DEFAULT_UPDATE_INTERVAL timedelta + itself - the options flow's SelectSelector default must match one of + its own option values or the schema fails to serialize (see #656). + """ + assert isinstance(DEFAULT_UPDATE_INTERVAL_OPTION, str) + assert DEFAULT_UPDATE_INTERVAL_OPTION in UPDATE_INTERVAL_OPTIONS + assert ( + UPDATE_INTERVAL_OPTIONS[DEFAULT_UPDATE_INTERVAL_OPTION] + == DEFAULT_UPDATE_INTERVAL + ) + def test_conf_update_interval_constant(self): assert CONF_UPDATE_INTERVAL == "update_interval" diff --git a/tests/test_schema_helper.py b/tests/test_schema_helper.py index 519348f3..0e3fadd2 100644 --- a/tests/test_schema_helper.py +++ b/tests/test_schema_helper.py @@ -2,11 +2,14 @@ from __future__ import annotations +import json from typing import Any, cast from homeassistant.const import CONF_HOST, CONF_PORT +import homeassistant.helpers.config_validation as cv import pytest import voluptuous as vol +import voluptuous_serialize from custom_components.luxtronik2.const import ( DEFAULT_PORT, @@ -92,3 +95,18 @@ def test_with_update_interval(self): current_interval="1 minute", ) assert isinstance(schema, vol.Schema) + + def test_default_schema_is_json_serializable(self): + """Regression test for issue #656. + + A config entry that has never saved options before (current_interval=None, + the common case) must not fall back to the raw DEFAULT_UPDATE_INTERVAL + timedelta - the frontend serializes this schema to JSON to render the + "Configure" form, and a timedelta default/suggested_value crashes that + with a 500 (TypeError: Object of type timedelta is not JSON serializable). + """ + schema = build_options_schema() + converted = voluptuous_serialize.convert( + schema, custom_serializer=cv.custom_serializer + ) + json.dumps(converted) # must not raise