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